From bd6b518aae61608ddc2d82b43ccc283dc95b9c54 Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期三, 11 三月 2026 13:59:33 +0800
Subject: [PATCH] #

---
 src/main/java/com/zy/system/controller/UserController.java           |    5 
 src/main/java/com/zy/system/mapper/UserMapper.java                   |    6 
 src/main/java/com/zy/system/service/UserService.java                 |    6 
 src/main/java/com/zy/system/entity/User.java                         |  128 ++++
 src/main/webapp/static/js/webauthn-utils.js                          |  141 +++++
 src/main/java/com/zy/system/service/impl/UserServiceImpl.java        |   13 
 src/main/webapp/static/js/login/login.js                             |   73 ++
 src/main/resources/mapper/UserMapper.xml                             |   57 ++
 src/main/resources/i18n/zh-CN/messages.properties                    |   14 
 src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java           |  268 +++++++++
 src/main/java/com/zy/common/web/AuthController.java                  |  321 +++++++++++
 src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql  |   26 
 src/main/webapp/views/detail.html                                    |   77 ++
 src/main/webapp/static/js/detail/detail.js                           |  177 ++++++
 src/main/resources/i18n/en-US/messages.properties                    |   14 
 src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java |   56 ++
 src/main/java/com/zy/common/auth/PasskeyChallengeManager.java        |  138 +++++
 src/main/webapp/views/login.html                                     |   49 +
 18 files changed, 1,544 insertions(+), 25 deletions(-)

diff --git a/src/main/java/com/zy/common/auth/PasskeyChallengeManager.java b/src/main/java/com/zy/common/auth/PasskeyChallengeManager.java
new file mode 100644
index 0000000..cbd5c82
--- /dev/null
+++ b/src/main/java/com/zy/common/auth/PasskeyChallengeManager.java
@@ -0,0 +1,138 @@
+package com.zy.common.auth;
+
+import com.core.common.Cools;
+import org.springframework.stereotype.Component;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class PasskeyChallengeManager {
+
+    private static final long EXPIRE_MILLIS = 5 * 60 * 1000L;
+
+    private final SecureRandom secureRandom = new SecureRandom();
+    private final ConcurrentHashMap<String, ChallengeState> holders = new ConcurrentHashMap<>();
+
+    public ChallengeState createRegistration(Long userId, String origin, String rpId) {
+        return create(Purpose.REGISTRATION, userId, origin, rpId);
+    }
+
+    public ChallengeState createAuthentication(Long userId, String origin, String rpId) {
+        return create(Purpose.AUTHENTICATION, userId, origin, rpId);
+    }
+
+    public ChallengeState get(String ticket, Purpose purpose) {
+        if (Cools.isEmpty(ticket) || purpose == null) {
+            return null;
+        }
+        ChallengeState state = holders.get(ticket);
+        if (state == null) {
+            return null;
+        }
+        if (state.expireAt < System.currentTimeMillis() || state.purpose != purpose) {
+            holders.remove(ticket);
+            return null;
+        }
+        return state;
+    }
+
+    public void remove(String ticket) {
+        if (!Cools.isEmpty(ticket)) {
+            holders.remove(ticket);
+        }
+    }
+
+    private ChallengeState create(Purpose purpose, Long userId, String origin, String rpId) {
+        cleanup();
+        ChallengeState state;
+        do {
+            state = new ChallengeState(
+                    randomTicket(),
+                    randomChallenge(),
+                    purpose,
+                    userId,
+                    origin,
+                    rpId,
+                    System.currentTimeMillis() + EXPIRE_MILLIS
+            );
+        } while (holders.putIfAbsent(state.ticket, state) != null);
+        return state;
+    }
+
+    private void cleanup() {
+        long now = System.currentTimeMillis();
+        for (Map.Entry<String, ChallengeState> entry : holders.entrySet()) {
+            if (entry.getValue().expireAt < now) {
+                holders.remove(entry.getKey());
+            }
+        }
+    }
+
+    private String randomTicket() {
+        byte[] bytes = new byte[24];
+        secureRandom.nextBytes(bytes);
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+    }
+
+    private String randomChallenge() {
+        byte[] bytes = new byte[32];
+        secureRandom.nextBytes(bytes);
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+    }
+
+    public enum Purpose {
+        REGISTRATION,
+        AUTHENTICATION
+    }
+
+    public static final class ChallengeState {
+        private final String ticket;
+        private final String challenge;
+        private final Purpose purpose;
+        private final Long userId;
+        private final String origin;
+        private final String rpId;
+        private final long expireAt;
+
+        private ChallengeState(String ticket, String challenge, Purpose purpose, Long userId, String origin, String rpId, long expireAt) {
+            this.ticket = ticket;
+            this.challenge = challenge;
+            this.purpose = purpose;
+            this.userId = userId;
+            this.origin = origin;
+            this.rpId = rpId;
+            this.expireAt = expireAt;
+        }
+
+        public String getTicket() {
+            return ticket;
+        }
+
+        public String getChallenge() {
+            return challenge;
+        }
+
+        public Purpose getPurpose() {
+            return purpose;
+        }
+
+        public Long getUserId() {
+            return userId;
+        }
+
+        public String getOrigin() {
+            return origin;
+        }
+
+        public String getRpId() {
+            return rpId;
+        }
+
+        public long getExpireAt() {
+            return expireAt;
+        }
+    }
+}
diff --git a/src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java b/src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java
new file mode 100644
index 0000000..80476a2
--- /dev/null
+++ b/src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java
@@ -0,0 +1,268 @@
+package com.zy.common.utils;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.core.common.Cools;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.MessageDigest;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.MGF1ParameterSpec;
+import java.security.spec.PSSParameterSpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Locale;
+
+public final class PasskeyWebAuthnUtil {
+
+    private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
+
+    private PasskeyWebAuthnUtil() {
+    }
+
+    public static JSONObject parseClientData(String clientDataJsonBase64Url) {
+        if (Cools.isEmpty(clientDataJsonBase64Url)) {
+            throw new IllegalArgumentException("Missing clientDataJSON");
+        }
+        String json = new String(decodeBase64Url(clientDataJsonBase64Url), StandardCharsets.UTF_8);
+        JSONObject clientData = JSON.parseObject(json);
+        if (clientData == null) {
+            throw new IllegalArgumentException("Invalid clientDataJSON");
+        }
+        return clientData;
+    }
+
+    public static void validateClientData(JSONObject clientData, String expectedType, String expectedChallenge, String expectedOrigin) {
+        if (clientData == null) {
+            throw new IllegalArgumentException("Missing clientData");
+        }
+        if (!Cools.eq(expectedType, clientData.getString("type"))) {
+            throw new IllegalArgumentException("Unexpected WebAuthn type");
+        }
+        if (!Cools.eq(expectedChallenge, clientData.getString("challenge"))) {
+            throw new IllegalArgumentException("Challenge mismatch");
+        }
+        if (!Cools.eq(expectedOrigin, clientData.getString("origin"))) {
+            throw new IllegalArgumentException("Origin mismatch");
+        }
+    }
+
+    public static AuthenticatorData validateAuthenticatorData(String authenticatorDataBase64Url, String rpId, boolean requireUserVerification) throws GeneralSecurityException {
+        byte[] authenticatorData = decodeBase64Url(authenticatorDataBase64Url);
+        if (authenticatorData.length < 37) {
+            throw new GeneralSecurityException("Invalid authenticator data");
+        }
+        byte[] expectedRpIdHash = sha256(rpId.getBytes(StandardCharsets.UTF_8));
+        for (int i = 0; i < expectedRpIdHash.length; i++) {
+            if (authenticatorData[i] != expectedRpIdHash[i]) {
+                throw new GeneralSecurityException("RP ID hash mismatch");
+            }
+        }
+        int flags = authenticatorData[32] & 0xFF;
+        if ((flags & 0x01) == 0) {
+            throw new GeneralSecurityException("User presence required");
+        }
+        if (requireUserVerification && (flags & 0x04) == 0) {
+            throw new GeneralSecurityException("User verification required");
+        }
+        long signCount = ByteBuffer.wrap(authenticatorData, 33, 4).getInt() & 0xFFFFFFFFL;
+        return new AuthenticatorData(authenticatorData, flags, signCount);
+    }
+
+    public static void verifyAssertionSignature(String publicKeyBase64Url,
+                                                Integer algorithm,
+                                                String authenticatorDataBase64Url,
+                                                String clientDataJsonBase64Url,
+                                                String signatureBase64Url) throws GeneralSecurityException {
+        PublicKey publicKey = readPublicKey(publicKeyBase64Url, algorithm);
+        Signature verifier = createSignatureVerifier(publicKey, algorithm);
+        verifier.initVerify(publicKey);
+        verifier.update(decodeBase64Url(authenticatorDataBase64Url));
+        verifier.update(sha256(decodeBase64Url(clientDataJsonBase64Url)));
+        if (!verifier.verify(decodeBase64Url(signatureBase64Url))) {
+            throw new GeneralSecurityException("Invalid passkey signature");
+        }
+    }
+
+    public static void ensurePublicKeyMaterial(String publicKeyBase64Url, Integer algorithm) throws GeneralSecurityException {
+        readPublicKey(publicKeyBase64Url, algorithm);
+    }
+
+    public static byte[] decodeBase64Url(String value) {
+        if (Cools.isEmpty(value)) {
+            throw new IllegalArgumentException("Missing base64Url value");
+        }
+        return URL_DECODER.decode(String.valueOf(value).trim());
+    }
+
+    public static String buildOrigin(HttpServletRequest request) {
+        String scheme = normalizeForwardedValue(request.getHeader("X-Forwarded-Proto"));
+        if (Cools.isEmpty(scheme)) {
+            scheme = request.getScheme();
+        }
+        String host = resolveHost(request);
+        return scheme.toLowerCase(Locale.ROOT) + "://" + host;
+    }
+
+    public static String buildRpId(HttpServletRequest request) {
+        String host = resolveHost(request);
+        if (host.startsWith("[")) {
+            int bracket = host.indexOf(']');
+            return bracket > 0 ? host.substring(1, bracket).toLowerCase(Locale.ROOT) : host.toLowerCase(Locale.ROOT);
+        }
+        int colonIndex = host.indexOf(':');
+        if (colonIndex >= 0) {
+            host = host.substring(0, colonIndex);
+        }
+        return host.toLowerCase(Locale.ROOT);
+    }
+
+    public static boolean isSecureOriginAllowed(String origin, String rpId) {
+        if (Cools.isEmpty(origin) || Cools.isEmpty(rpId)) {
+            return false;
+        }
+        String lowerOrigin = origin.toLowerCase(Locale.ROOT);
+        String lowerRpId = rpId.toLowerCase(Locale.ROOT);
+        if ("localhost".equals(lowerRpId)
+                || "127.0.0.1".equals(lowerRpId)
+                || "::1".equals(lowerRpId)
+                || lowerRpId.endsWith(".localhost")) {
+            return true;
+        }
+        return lowerOrigin.startsWith("https://");
+    }
+
+    public static byte[] buildUserHandle(Long userId) {
+        return String.valueOf(userId).getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static PublicKey readPublicKey(String publicKeyBase64Url, Integer algorithm) throws GeneralSecurityException {
+        byte[] encoded = decodeBase64Url(publicKeyBase64Url);
+        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
+        List<String> keyFactories = keyFactoriesForAlgorithm(algorithm);
+        GeneralSecurityException failure = null;
+        for (String keyFactoryName : keyFactories) {
+            try {
+                return KeyFactory.getInstance(keyFactoryName).generatePublic(keySpec);
+            } catch (GeneralSecurityException ex) {
+                failure = ex;
+            }
+        }
+        throw failure == null ? new GeneralSecurityException("Unsupported passkey algorithm") : failure;
+    }
+
+    private static Signature createSignatureVerifier(PublicKey publicKey, Integer algorithm) throws GeneralSecurityException {
+        int value = algorithm == null ? Integer.MIN_VALUE : algorithm;
+        switch (value) {
+            case -7:
+                return Signature.getInstance("SHA256withECDSA");
+            case -257:
+                return Signature.getInstance("SHA256withRSA");
+            case -37:
+                Signature pss = Signature.getInstance("RSASSA-PSS");
+                pss.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
+                return pss;
+            case -8:
+                return Signature.getInstance("Ed25519");
+            default:
+                if ("EC".equalsIgnoreCase(publicKey.getAlgorithm())) {
+                    return Signature.getInstance("SHA256withECDSA");
+                }
+                if ("RSA".equalsIgnoreCase(publicKey.getAlgorithm())) {
+                    return Signature.getInstance("SHA256withRSA");
+                }
+                if ("Ed25519".equalsIgnoreCase(publicKey.getAlgorithm()) || "EdDSA".equalsIgnoreCase(publicKey.getAlgorithm())) {
+                    return Signature.getInstance("Ed25519");
+                }
+                throw new GeneralSecurityException("Unsupported passkey signature algorithm");
+        }
+    }
+
+    private static List<String> keyFactoriesForAlgorithm(Integer algorithm) {
+        List<String> result = new ArrayList<>();
+        int value = algorithm == null ? Integer.MIN_VALUE : algorithm;
+        switch (value) {
+            case -7:
+                result.add("EC");
+                break;
+            case -257:
+            case -37:
+                result.add("RSA");
+                break;
+            case -8:
+                result.add("Ed25519");
+                result.add("EdDSA");
+                break;
+            default:
+                result.add("EC");
+                result.add("RSA");
+                result.add("Ed25519");
+                result.add("EdDSA");
+                break;
+        }
+        return result;
+    }
+
+    private static String resolveHost(HttpServletRequest request) {
+        String host = normalizeForwardedValue(request.getHeader("X-Forwarded-Host"));
+        if (Cools.isEmpty(host)) {
+            host = request.getServerName();
+            int port = request.getServerPort();
+            if (port > 0 && port != 80 && port != 443) {
+                host = host + ":" + port;
+            }
+        }
+        String port = normalizeForwardedValue(request.getHeader("X-Forwarded-Port"));
+        if (!Cools.isEmpty(port) && host.indexOf(':') < 0 && !host.startsWith("[")) {
+            host = host + ":" + port;
+        }
+        return host;
+    }
+
+    private static String normalizeForwardedValue(String value) {
+        if (Cools.isEmpty(value)) {
+            return null;
+        }
+        String normalized = String.valueOf(value).trim();
+        int commaIndex = normalized.indexOf(',');
+        if (commaIndex >= 0) {
+            normalized = normalized.substring(0, commaIndex).trim();
+        }
+        return normalized;
+    }
+
+    private static byte[] sha256(byte[] data) throws GeneralSecurityException {
+        return MessageDigest.getInstance("SHA-256").digest(data);
+    }
+
+    public static final class AuthenticatorData {
+        private final byte[] raw;
+        private final int flags;
+        private final long signCount;
+
+        private AuthenticatorData(byte[] raw, int flags, long signCount) {
+            this.raw = raw;
+            this.flags = flags;
+            this.signCount = signCount;
+        }
+
+        public byte[] getRaw() {
+            return raw;
+        }
+
+        public int getFlags() {
+            return flags;
+        }
+
+        public long getSignCount() {
+            return signCount;
+        }
+    }
+}
diff --git a/src/main/java/com/zy/common/web/AuthController.java b/src/main/java/com/zy/common/web/AuthController.java
index 89762e9..0d3d427 100644
--- a/src/main/java/com/zy/common/web/AuthController.java
+++ b/src/main/java/com/zy/common/web/AuthController.java
@@ -8,11 +8,13 @@
 import com.core.exception.CoolException;
 import com.zy.common.CodeRes;
 import com.zy.common.auth.MfaLoginTicketManager;
+import com.zy.common.auth.PasskeyChallengeManager;
 import com.zy.common.i18n.I18nMessageService;
 import com.zy.common.entity.Parameter;
 import com.zy.common.model.PowerDto;
 import com.zy.common.model.enums.HtmlNavIconType;
 import com.zy.common.utils.MfaTotpUtil;
+import com.zy.common.utils.PasskeyWebAuthnUtil;
 import com.zy.common.utils.QrCode;
 import com.zy.common.utils.RandomValidateCodeUtil;
 import com.zy.system.entity.*;
@@ -61,6 +63,8 @@
     private I18nMessageService i18nMessageService;
     @Autowired
     private MfaLoginTicketManager mfaLoginTicketManager;
+    @Autowired
+    private PasskeyChallengeManager passkeyChallengeManager;
 
     @RequestMapping("/login.action")
     @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "鐧诲綍")
@@ -78,7 +82,7 @@
             res.put("token", Cools.enToken(System.currentTimeMillis() + mobile, superPwd));
             return R.ok(res);
         }
-        User user = userService.getByMobileWithMfa(mobile);
+        User user = userService.getByMobileWithSecurity(mobile);
         if (Cools.isEmpty(user)){
             return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
         }
@@ -105,7 +109,7 @@
         if (userId == null) {
             return new R(10004, i18nMessageService.getMessage("response.user.mfaTicketExpired"));
         }
-        User user = userService.getByIdWithMfa(userId);
+        User user = userService.getByIdWithSecurity(userId);
         if (Cools.isEmpty(user)) {
             mfaLoginTicketManager.remove(ticket);
             return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
@@ -123,6 +127,88 @@
         }
         mfaLoginTicketManager.remove(ticket);
         return R.ok(buildLoginSuccess(user));
+    }
+
+    @RequestMapping("/login/passkey/options.action")
+    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "閫氳瀵嗛挜鐧诲綍鍙傛暟")
+    public R loginPasskeyOptions(String mobile) {
+        if (!licenseTimer.getSystemSupport()) {
+            return new R(20001, i18nMessageService.getMessage("response.system.licenseExpired"));
+        }
+        String origin = PasskeyWebAuthnUtil.buildOrigin(request);
+        String rpId = PasskeyWebAuthnUtil.buildRpId(request);
+        if (!PasskeyWebAuthnUtil.isSecureOriginAllowed(origin, rpId)) {
+            return new R(10009, i18nMessageService.getMessage("response.user.passkeySecureContextRequired"));
+        }
+        User user = null;
+        if (!Cools.isEmpty(mobile)) {
+            user = userService.getByMobileWithSecurity(mobile);
+            if (Cools.isEmpty(user)) {
+                return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
+            }
+            if (user.getStatus() != 1) {
+                return new R(10002, i18nMessageService.getMessage("response.user.disabled"));
+            }
+            if (!hasPasskeyBound(user)) {
+                return new R(10010, i18nMessageService.getMessage("response.user.passkeyNotBound"));
+            }
+        }
+        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.createAuthentication(user == null ? null : user.getId(), origin, rpId);
+        return R.ok(buildPasskeyAuthenticationOptions(state, user));
+    }
+
+    @RequestMapping("/login/passkey/verify.action")
+    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "閫氳瀵嗛挜鐧诲綍")
+    public R loginPasskeyVerify(String ticket,
+                                String credentialId,
+                                String clientDataJSON,
+                                String authenticatorData,
+                                String signature) {
+        if (!licenseTimer.getSystemSupport()) {
+            return new R(20001, i18nMessageService.getMessage("response.system.licenseExpired"));
+        }
+        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.get(ticket, PasskeyChallengeManager.Purpose.AUTHENTICATION);
+        if (state == null) {
+            return new R(10011, i18nMessageService.getMessage("response.user.passkeyTicketExpired"));
+        }
+        try {
+            com.alibaba.fastjson.JSONObject clientData = PasskeyWebAuthnUtil.parseClientData(clientDataJSON);
+            PasskeyWebAuthnUtil.validateClientData(clientData, "webauthn.get", state.getChallenge(), state.getOrigin());
+            PasskeyWebAuthnUtil.AuthenticatorData authData = PasskeyWebAuthnUtil.validateAuthenticatorData(authenticatorData, state.getRpId(), true);
+            User user = state.getUserId() == null
+                    ? userService.getByPasskeyCredentialId(credentialId)
+                    : userService.getByIdWithSecurity(state.getUserId());
+            if (Cools.isEmpty(user)) {
+                return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
+            }
+            if (user.getStatus() != 1) {
+                return new R(10002, i18nMessageService.getMessage("response.user.disabled"));
+            }
+            if (!hasPasskeyBound(user) || !Cools.eq(user.getPasskeyCredentialId(), credentialId)) {
+                return new R(10010, i18nMessageService.getMessage("response.user.passkeyNotBound"));
+            }
+            PasskeyWebAuthnUtil.verifyAssertionSignature(
+                    user.getPasskeyPublicKey(),
+                    user.getPasskeyAlgorithm(),
+                    authenticatorData,
+                    clientDataJSON,
+                    signature
+            );
+            long nextSignCount = authData.getSignCount();
+            Long currentSignCount = user.getPasskeySignCount();
+            if (currentSignCount != null && currentSignCount > 0 && nextSignCount > 0 && nextSignCount <= currentSignCount) {
+                return new R(10012, i18nMessageService.getMessage("response.user.passkeyCounterMismatch"));
+            }
+            userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
+                    .eq("id", user.getId())
+                    .set("passkey_sign_count", nextSignCount)
+                    .set("passkey_last_used_time", new Date()));
+            return R.ok(buildLoginSuccess(user));
+        } catch (Exception ex) {
+            return new R(10013, i18nMessageService.getMessage("response.user.passkeyVerifyFailed"));
+        } finally {
+            passkeyChallengeManager.remove(ticket);
+        }
     }
 
     @RequestMapping("/code/switch.action")
@@ -155,7 +241,7 @@
     @RequestMapping("/user/detail/auth")
     @ManagerAuth
     public R userDetail(){
-        User user = userService.getByIdWithMfa(getUserId());
+        User user = userService.getByIdWithSecurity(getUserId());
         if (Cools.isEmpty(user)) {
             return R.ok();
         }
@@ -165,7 +251,7 @@
     @RequestMapping("/user/mfa/setup/auth")
     @ManagerAuth
     public R userMfaSetup() {
-        User user = userService.getByIdWithMfa(getUserId());
+        User user = userService.getByIdWithSecurity(getUserId());
         if (Cools.isEmpty(user)) {
             return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
         }
@@ -188,7 +274,7 @@
     @ManagerAuth
     @Transactional
     public R userMfaEnable(String currentPassword, String secret, String code) {
-        User user = userService.getByIdWithMfa(getUserId());
+        User user = userService.getByIdWithSecurity(getUserId());
         if (Cools.isEmpty(user)) {
             return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
         }
@@ -215,7 +301,7 @@
     @ManagerAuth
     @Transactional
     public R userMfaDisable(String currentPassword, String code) {
-        User user = userService.getByIdWithMfa(getUserId());
+        User user = userService.getByIdWithSecurity(getUserId());
         if (Cools.isEmpty(user)) {
             return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
         }
@@ -233,6 +319,108 @@
                 .set("mfa_enabled", 0)
                 .set("mfa_secret", null)
                 .set("mfa_bound_time", null));
+        return R.ok();
+    }
+
+    @RequestMapping("/user/passkey/register/options/auth")
+    @ManagerAuth
+    public R userPasskeyRegisterOptions() {
+        User user = userService.getByIdWithSecurity(getUserId());
+        if (Cools.isEmpty(user)) {
+            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
+        }
+        if (hasPasskeyBound(user)) {
+            return new R(10014, i18nMessageService.getMessage("response.user.passkeyAlreadyBound"));
+        }
+        String origin = PasskeyWebAuthnUtil.buildOrigin(request);
+        String rpId = PasskeyWebAuthnUtil.buildRpId(request);
+        if (!PasskeyWebAuthnUtil.isSecureOriginAllowed(origin, rpId)) {
+            return new R(10009, i18nMessageService.getMessage("response.user.passkeySecureContextRequired"));
+        }
+        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.createRegistration(user.getId(), origin, rpId);
+        return R.ok(buildPasskeyRegistrationOptions(state, user));
+    }
+
+    @RequestMapping("/user/passkey/register/finish/auth")
+    @ManagerAuth
+    @Transactional
+    public R userPasskeyRegisterFinish(String ticket,
+                                       String currentPassword,
+                                       String name,
+                                       String credentialId,
+                                       String clientDataJSON,
+                                       String authenticatorData,
+                                       String publicKey,
+                                       Integer publicKeyAlgorithm,
+                                       String transports) {
+        User user = userService.getByIdWithSecurity(getUserId());
+        if (Cools.isEmpty(user)) {
+            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
+        }
+        if (!Cools.eq(user.getPassword(), currentPassword)) {
+            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
+        }
+        if (hasPasskeyBound(user)) {
+            return new R(10014, i18nMessageService.getMessage("response.user.passkeyAlreadyBound"));
+        }
+        PasskeyChallengeManager.ChallengeState state = passkeyChallengeManager.get(ticket, PasskeyChallengeManager.Purpose.REGISTRATION);
+        if (state == null || !Objects.equals(state.getUserId(), user.getId())) {
+            return new R(10011, i18nMessageService.getMessage("response.user.passkeyTicketExpired"));
+        }
+        try {
+            com.alibaba.fastjson.JSONObject clientData = PasskeyWebAuthnUtil.parseClientData(clientDataJSON);
+            PasskeyWebAuthnUtil.validateClientData(clientData, "webauthn.create", state.getChallenge(), state.getOrigin());
+            PasskeyWebAuthnUtil.AuthenticatorData authData = PasskeyWebAuthnUtil.validateAuthenticatorData(authenticatorData, state.getRpId(), true);
+            PasskeyWebAuthnUtil.ensurePublicKeyMaterial(publicKey, publicKeyAlgorithm);
+            if (Cools.isEmpty(credentialId)) {
+                return new R(10013, i18nMessageService.getMessage("response.user.passkeyRegisterFailed"));
+            }
+            User exist = userService.getByPasskeyCredentialId(credentialId);
+            if (!Cools.isEmpty(exist) && !Objects.equals(exist.getId(), user.getId())) {
+                return new R(10015, i18nMessageService.getMessage("response.user.passkeyCredentialExists"));
+            }
+            userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
+                    .eq("id", user.getId())
+                    .set("passkey_name", normalizePasskeyName(name))
+                    .set("passkey_credential_id", credentialId)
+                    .set("passkey_public_key", publicKey)
+                    .set("passkey_algorithm", publicKeyAlgorithm)
+                    .set("passkey_sign_count", authData.getSignCount())
+                    .set("passkey_transports", normalizePasskeyTransports(transports))
+                    .set("passkey_bound_time", new Date())
+                    .set("passkey_last_used_time", null));
+            return R.ok();
+        } catch (Exception ex) {
+            return new R(10016, i18nMessageService.getMessage("response.user.passkeyRegisterFailed"));
+        } finally {
+            passkeyChallengeManager.remove(ticket);
+        }
+    }
+
+    @RequestMapping("/user/passkey/remove/auth")
+    @ManagerAuth
+    @Transactional
+    public R userPasskeyRemove(String currentPassword) {
+        User user = userService.getByIdWithSecurity(getUserId());
+        if (Cools.isEmpty(user)) {
+            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
+        }
+        if (!hasPasskeyBound(user)) {
+            return new R(10010, i18nMessageService.getMessage("response.user.passkeyNotBound"));
+        }
+        if (!Cools.eq(user.getPassword(), currentPassword)) {
+            return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch"));
+        }
+        userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>()
+                .eq("id", user.getId())
+                .set("passkey_name", null)
+                .set("passkey_credential_id", null)
+                .set("passkey_public_key", null)
+                .set("passkey_algorithm", null)
+                .set("passkey_sign_count", 0)
+                .set("passkey_transports", null)
+                .set("passkey_bound_time", null)
+                .set("passkey_last_used_time", null));
         return R.ok();
     }
 
@@ -379,6 +567,12 @@
                 && !Cools.isEmpty(user.getMfaSecret());
     }
 
+    private boolean hasPasskeyBound(User user) {
+        return user != null
+                && !Cools.isEmpty(user.getPasskeyCredentialId())
+                && !Cools.isEmpty(user.getPasskeyPublicKey());
+    }
+
     private Map<String, Object> buildSafeUserDetail(User user) {
         Map<String, Object> result = new HashMap<>();
         result.put("id", user.getId());
@@ -392,7 +586,70 @@
         result.put("mfaEnabled$", user.getMfaEnabled$());
         result.put("mfaBoundTime$", user.getMfaBoundTime$());
         result.put("mfaMaskedSecret", MfaTotpUtil.maskSecret(user.getMfaSecret()));
+        result.put("passkeyBound", hasPasskeyBound(user));
+        result.put("passkeyName", user.getPasskeyName());
+        result.put("passkeyBoundTime$", user.getPasskeyBoundTime$());
+        result.put("passkeyLastUsedTime$", user.getPasskeyLastUsedTime$());
+        result.put("passkeyTransports", user.getPasskeyTransports());
         return result;
+    }
+
+    private Map<String, Object> buildPasskeyRegistrationOptions(PasskeyChallengeManager.ChallengeState state, User user) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("ticket", state.getTicket());
+        data.put("challenge", state.getChallenge());
+        data.put("rpId", state.getRpId());
+        data.put("rpName", "WCS");
+        data.put("userId", Base64.getUrlEncoder().withoutPadding().encodeToString(PasskeyWebAuthnUtil.buildUserHandle(user.getId())));
+        data.put("userName", resolvePasskeyUserName(user));
+        data.put("userDisplayName", resolvePasskeyDisplayName(user));
+        data.put("timeout", 60000);
+        data.put("attestation", "none");
+        data.put("pubKeyCredParams", buildPasskeyAlgorithms());
+        Map<String, Object> authenticatorSelection = new HashMap<>();
+        authenticatorSelection.put("residentKey", "preferred");
+        authenticatorSelection.put("userVerification", "required");
+        data.put("authenticatorSelection", authenticatorSelection);
+        data.put("excludeCredentials", Collections.emptyList());
+        return data;
+    }
+
+    private Map<String, Object> buildPasskeyAuthenticationOptions(PasskeyChallengeManager.ChallengeState state, User user) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("ticket", state.getTicket());
+        data.put("challenge", state.getChallenge());
+        data.put("rpId", state.getRpId());
+        data.put("timeout", 60000);
+        data.put("userVerification", "required");
+        if (user == null) {
+            data.put("allowCredentials", Collections.emptyList());
+        } else {
+            data.put("allowCredentials", Collections.singletonList(buildPasskeyDescriptor(user.getPasskeyCredentialId(), user.getPasskeyTransports())));
+        }
+        return data;
+    }
+
+    private List<Map<String, Object>> buildPasskeyAlgorithms() {
+        List<Map<String, Object>> result = new ArrayList<>();
+        result.add(buildPasskeyAlgorithm(-7));
+        result.add(buildPasskeyAlgorithm(-257));
+        result.add(buildPasskeyAlgorithm(-8));
+        return result;
+    }
+
+    private Map<String, Object> buildPasskeyAlgorithm(int alg) {
+        Map<String, Object> item = new HashMap<>();
+        item.put("type", "public-key");
+        item.put("alg", alg);
+        return item;
+    }
+
+    private Map<String, Object> buildPasskeyDescriptor(String credentialId, String transports) {
+        Map<String, Object> item = new HashMap<>();
+        item.put("type", "public-key");
+        item.put("id", credentialId);
+        item.put("transports", parsePasskeyTransports(transports));
+        return item;
     }
 
     private String renderQrCodeDataUri(String content) {
@@ -417,6 +674,58 @@
                 .toUpperCase(Locale.ROOT);
     }
 
+    private String resolvePasskeyUserName(User user) {
+        if (!Cools.isEmpty(user.getMobile())) {
+            return user.getMobile();
+        }
+        if (!Cools.isEmpty(user.getUsername())) {
+            return user.getUsername();
+        }
+        return String.valueOf(user.getId());
+    }
+
+    private String resolvePasskeyDisplayName(User user) {
+        if (!Cools.isEmpty(user.getUsername())) {
+            return user.getUsername();
+        }
+        return resolvePasskeyUserName(user);
+    }
+
+    private String normalizePasskeyName(String name) {
+        String value = Cools.isEmpty(name) ? "" : String.valueOf(name).trim();
+        if (value.length() > 100) {
+            value = value.substring(0, 100);
+        }
+        if (!Cools.isEmpty(value)) {
+            return value;
+        }
+        return "閫氳瀵嗛挜-" + new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+    }
+
+    private String normalizePasskeyTransports(String transports) {
+        List<String> values = parsePasskeyTransports(transports);
+        return values.isEmpty() ? null : JSON.toJSONString(values);
+    }
+
+    private List<String> parsePasskeyTransports(String transports) {
+        if (Cools.isEmpty(transports)) {
+            return Collections.emptyList();
+        }
+        try {
+            List<String> values = JSON.parseArray(transports, String.class);
+            return values == null ? Collections.emptyList() : values;
+        } catch (Exception ignored) {
+        }
+        List<String> result = new ArrayList<>();
+        for (String value : String.valueOf(transports).split(",")) {
+            String item = value == null ? "" : value.trim();
+            if (!item.isEmpty()) {
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
     private String localizeResourceName(Resource resource) {
         return i18nMessageService.resolveResourceText(resource.getName(), resource.getCode(), resource.getId());
     }
diff --git a/src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java b/src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java
new file mode 100644
index 0000000..077e5b6
--- /dev/null
+++ b/src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java
@@ -0,0 +1,56 @@
+package com.zy.system.config;
+
+import org.springframework.stereotype.Component;
+
+import jakarta.annotation.PostConstruct;
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+@Component
+public class UserPasskeySchemaInitializer {
+
+    private final DataSource dataSource;
+
+    public UserPasskeySchemaInitializer(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @PostConstruct
+    public void init() {
+        ensureColumn("sys_user", "passkey_name", "VARCHAR(100)");
+        ensureColumn("sys_user", "passkey_credential_id", "VARCHAR(255)");
+        ensureColumn("sys_user", "passkey_public_key", "TEXT");
+        ensureColumn("sys_user", "passkey_algorithm", "INT");
+        ensureColumn("sys_user", "passkey_sign_count", "BIGINT DEFAULT 0");
+        ensureColumn("sys_user", "passkey_transports", "VARCHAR(255)");
+        ensureColumn("sys_user", "passkey_bound_time", "DATETIME NULL");
+        ensureColumn("sys_user", "passkey_last_used_time", "DATETIME NULL");
+    }
+
+    private void ensureColumn(String tableName, String columnName, String columnDefinition) {
+        try (Connection connection = dataSource.getConnection()) {
+            if (hasColumn(connection, tableName, columnName)) {
+                return;
+            }
+            try (Statement statement = connection.createStatement()) {
+                statement.executeUpdate("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnDefinition);
+            }
+        } catch (Exception ignored) {
+        }
+    }
+
+    private boolean hasColumn(Connection connection, String tableName, String columnName) throws Exception {
+        DatabaseMetaData metaData = connection.getMetaData();
+        try (ResultSet resultSet = metaData.getColumns(connection.getCatalog(), null, tableName, null)) {
+            while (resultSet.next()) {
+                if (columnName.equalsIgnoreCase(resultSet.getString("COLUMN_NAME"))) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+}
diff --git a/src/main/java/com/zy/system/controller/UserController.java b/src/main/java/com/zy/system/controller/UserController.java
index 9cad12b..b7fefb9 100644
--- a/src/main/java/com/zy/system/controller/UserController.java
+++ b/src/main/java/com/zy/system/controller/UserController.java
@@ -269,6 +269,11 @@
         }
         user.setPassword(null);
         user.setMfaSecret(null);
+        user.setPasskeyCredentialId(null);
+        user.setPasskeyPublicKey(null);
+        user.setPasskeyAlgorithm(null);
+        user.setPasskeySignCount(null);
+        user.setPasskeyTransports(null);
     }
 
 }
diff --git a/src/main/java/com/zy/system/entity/User.java b/src/main/java/com/zy/system/entity/User.java
index 78f470d..f7707ff 100644
--- a/src/main/java/com/zy/system/entity/User.java
+++ b/src/main/java/com/zy/system/entity/User.java
@@ -74,6 +74,56 @@
     private Date mfaBoundTime;
 
     /**
+     * 閫氳瀵嗛挜鍚嶇О
+     */
+    @TableField("passkey_name")
+    private String passkeyName;
+
+    /**
+     * 閫氳瀵嗛挜鍑瘉ID
+     */
+    @TableField(value = "passkey_credential_id", select = false)
+    private String passkeyCredentialId;
+
+    /**
+     * 閫氳瀵嗛挜鍏挜锛圫PKI Base64Url锛�
+     */
+    @TableField(value = "passkey_public_key", select = false)
+    private String passkeyPublicKey;
+
+    /**
+     * 閫氳瀵嗛挜绠楁硶
+     */
+    @TableField(value = "passkey_algorithm", select = false)
+    private Integer passkeyAlgorithm;
+
+    /**
+     * 閫氳瀵嗛挜绛惧悕璁℃暟鍣�
+     */
+    @TableField(value = "passkey_sign_count", select = false)
+    private Long passkeySignCount;
+
+    /**
+     * 閫氳瀵嗛挜浼犺緭鏂瑰紡
+     */
+    @TableField(value = "passkey_transports", select = false)
+    private String passkeyTransports;
+
+    /**
+     * 閫氳瀵嗛挜缁戝畾鏃堕棿
+     */
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @TableField("passkey_bound_time")
+    private Date passkeyBoundTime;
+
+    /**
+     * 閫氳瀵嗛挜鏈�杩戜娇鐢ㄦ椂闂�
+     */
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @TableField("passkey_last_used_time")
+    private Date passkeyLastUsedTime;
+
+    /**
      * 瑙掕壊
      */
     @TableField("role_id")
@@ -193,6 +243,84 @@
         this.mfaBoundTime = mfaBoundTime;
     }
 
+    public String getPasskeyName() {
+        return passkeyName;
+    }
+
+    public void setPasskeyName(String passkeyName) {
+        this.passkeyName = passkeyName;
+    }
+
+    public String getPasskeyCredentialId() {
+        return passkeyCredentialId;
+    }
+
+    public void setPasskeyCredentialId(String passkeyCredentialId) {
+        this.passkeyCredentialId = passkeyCredentialId;
+    }
+
+    public String getPasskeyPublicKey() {
+        return passkeyPublicKey;
+    }
+
+    public void setPasskeyPublicKey(String passkeyPublicKey) {
+        this.passkeyPublicKey = passkeyPublicKey;
+    }
+
+    public Integer getPasskeyAlgorithm() {
+        return passkeyAlgorithm;
+    }
+
+    public void setPasskeyAlgorithm(Integer passkeyAlgorithm) {
+        this.passkeyAlgorithm = passkeyAlgorithm;
+    }
+
+    public Long getPasskeySignCount() {
+        return passkeySignCount;
+    }
+
+    public void setPasskeySignCount(Long passkeySignCount) {
+        this.passkeySignCount = passkeySignCount;
+    }
+
+    public String getPasskeyTransports() {
+        return passkeyTransports;
+    }
+
+    public void setPasskeyTransports(String passkeyTransports) {
+        this.passkeyTransports = passkeyTransports;
+    }
+
+    public Date getPasskeyBoundTime() {
+        return passkeyBoundTime;
+    }
+
+    public String getPasskeyBoundTime$() {
+        if (Cools.isEmpty(this.passkeyBoundTime)) {
+            return "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.passkeyBoundTime);
+    }
+
+    public void setPasskeyBoundTime(Date passkeyBoundTime) {
+        this.passkeyBoundTime = passkeyBoundTime;
+    }
+
+    public Date getPasskeyLastUsedTime() {
+        return passkeyLastUsedTime;
+    }
+
+    public String getPasskeyLastUsedTime$() {
+        if (Cools.isEmpty(this.passkeyLastUsedTime)) {
+            return "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.passkeyLastUsedTime);
+    }
+
+    public void setPasskeyLastUsedTime(Date passkeyLastUsedTime) {
+        this.passkeyLastUsedTime = passkeyLastUsedTime;
+    }
+
     public Long getRoleId() {
         return roleId;
     }
diff --git a/src/main/java/com/zy/system/mapper/UserMapper.java b/src/main/java/com/zy/system/mapper/UserMapper.java
index 6ebb382..99729a1 100644
--- a/src/main/java/com/zy/system/mapper/UserMapper.java
+++ b/src/main/java/com/zy/system/mapper/UserMapper.java
@@ -10,7 +10,9 @@
 @Repository
 public interface UserMapper extends BaseMapper<User> {
 
-    User selectByMobileWithMfa(@Param("mobile") String mobile);
+    User selectByMobileWithSecurity(@Param("mobile") String mobile);
 
-    User selectByIdWithMfa(@Param("id") Long id);
+    User selectByIdWithSecurity(@Param("id") Long id);
+
+    User selectByPasskeyCredentialId(@Param("credentialId") String credentialId);
 }
diff --git a/src/main/java/com/zy/system/service/UserService.java b/src/main/java/com/zy/system/service/UserService.java
index 63fcbc4..6f10f1a 100644
--- a/src/main/java/com/zy/system/service/UserService.java
+++ b/src/main/java/com/zy/system/service/UserService.java
@@ -5,7 +5,9 @@
 
 public interface UserService extends IService<User> {
 
-    User getByMobileWithMfa(String mobile);
+    User getByMobileWithSecurity(String mobile);
 
-    User getByIdWithMfa(Long id);
+    User getByIdWithSecurity(Long id);
+
+    User getByPasskeyCredentialId(String credentialId);
 }
diff --git a/src/main/java/com/zy/system/service/impl/UserServiceImpl.java b/src/main/java/com/zy/system/service/impl/UserServiceImpl.java
index 475733c..c7b1aaa 100644
--- a/src/main/java/com/zy/system/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/zy/system/service/impl/UserServiceImpl.java
@@ -10,12 +10,17 @@
 public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
 
     @Override
-    public User getByMobileWithMfa(String mobile) {
-        return baseMapper.selectByMobileWithMfa(mobile);
+    public User getByMobileWithSecurity(String mobile) {
+        return baseMapper.selectByMobileWithSecurity(mobile);
     }
 
     @Override
-    public User getByIdWithMfa(Long id) {
-        return baseMapper.selectByIdWithMfa(id);
+    public User getByIdWithSecurity(Long id) {
+        return baseMapper.selectByIdWithSecurity(id);
+    }
+
+    @Override
+    public User getByPasskeyCredentialId(String credentialId) {
+        return baseMapper.selectByPasskeyCredentialId(credentialId);
     }
 }
diff --git a/src/main/resources/i18n/en-US/messages.properties b/src/main/resources/i18n/en-US/messages.properties
index 2d7e9ec..d0b1d8e 100644
--- a/src/main/resources/i18n/en-US/messages.properties
+++ b/src/main/resources/i18n/en-US/messages.properties
@@ -30,6 +30,10 @@
 login.username=Account
 login.password=Password
 login.submit=Sign In
+login.passkey.submit=Sign In with Passkey
+login.passkey.tip=Use device biometrics or a security key to sign in. Entering the account narrows the credential scope; leaving it blank tries discoverable credentials.
+login.passkey.browserUnsupported=This browser does not support passkeys. Use a recent Chrome, Edge, or Safari build
+login.passkey.secureContext=Passkeys require HTTPS or localhost
 login.tools.title=System Tools
 login.tools.recommended=Recommended Actions
 login.tools.recommendedDesc=Use "Get Request Code" and "Activate" first to complete license application and activation.
@@ -65,6 +69,8 @@
 login.validation.mfaRequired=Please enter the 6-digit verification code
 login.validation.mfaInvalid=Please enter a 6-digit numeric verification code
 login.error.loginFailed=Login failed
+login.error.passkeyOptionsFailed=Failed to load passkey sign-in options
+login.error.passkeyVerifyFailed=Passkey verification failed
 login.error.mfaTicketExpired=The login ticket has expired. Please sign in again
 login.error.mfaFailed=Verification failed
 login.error.requestCodeFailed=Failed to get request code
@@ -99,6 +105,14 @@
 response.user.notFound=Account does not exist
 response.user.disabled=Account is disabled
 response.user.passwordMismatch=Incorrect password
+response.user.passkeySecureContextRequired=Passkeys require HTTPS or localhost
+response.user.passkeyNotBound=This account has not bound a passkey yet
+response.user.passkeyTicketExpired=The passkey login ticket has expired. Please try again
+response.user.passkeyCounterMismatch=The passkey signature counter is invalid. Rebind the passkey and try again
+response.user.passkeyVerifyFailed=Passkey verification failed
+response.user.passkeyAlreadyBound=This account already has a passkey bound. Remove it before binding another one
+response.user.passkeyCredentialExists=This passkey is already bound to another account
+response.user.passkeyRegisterFailed=Failed to bind the passkey
 response.user.oldPasswordMismatch=Current password is incorrect
 response.user.mfaNotAllowed=MFA is not enabled for this account
 response.user.mfaNotEnabled=MFA is not configured for this account
diff --git a/src/main/resources/i18n/zh-CN/messages.properties b/src/main/resources/i18n/zh-CN/messages.properties
index 3c465bd..d31f592 100644
--- a/src/main/resources/i18n/zh-CN/messages.properties
+++ b/src/main/resources/i18n/zh-CN/messages.properties
@@ -30,6 +30,10 @@
 login.username=璐﹀彿
 login.password=瀵嗙爜
 login.submit=鐧诲綍
+login.passkey.submit=閫氳瀵嗛挜鐧诲綍
+login.passkey.tip=鏀寔浣跨敤璁惧鐢熺墿璇嗗埆鎴栧畨鍏ㄥ瘑閽ョ櫥褰曘�傚彲鍏堣緭鍏ヨ处鍙风缉灏忓嚟璇佽寖鍥达紝鐣欑┖鍒欏皾璇曞彂鐜板紡鐧诲綍銆�
+login.passkey.browserUnsupported=褰撳墠娴忚鍣ㄤ笉鏀寔閫氳瀵嗛挜锛岃浣跨敤鏈�鏂扮増 Chrome銆丒dge 鎴� Safari
+login.passkey.secureContext=閫氳瀵嗛挜浠呮敮鎸佸湪 HTTPS 鎴� localhost 鐜涓嬩娇鐢�
 login.tools.title=绯荤粺宸ュ叿
 login.tools.recommended=鎺ㄨ崘鎿嶄綔
 login.tools.recommendedDesc=浼樺厛浣跨敤鈥滆幏鍙栬姹傜爜鈥濆拰鈥滀竴閿縺娲烩�濆畬鎴愯鍙瘉鐢宠涓庢縺娲汇��
@@ -65,6 +69,8 @@
 login.validation.mfaRequired=璇疯緭鍏�6浣嶉獙璇佺爜
 login.validation.mfaInvalid=璇疯緭鍏�6浣嶆暟瀛楅獙璇佺爜
 login.error.loginFailed=鐧诲綍澶辫触
+login.error.passkeyOptionsFailed=鑾峰彇閫氳瀵嗛挜鐧诲綍鍙傛暟澶辫触
+login.error.passkeyVerifyFailed=閫氳瀵嗛挜楠岃瘉澶辫触
 login.error.mfaTicketExpired=鐧诲綍绁ㄦ嵁宸插け鏁堬紝璇烽噸鏂扮櫥褰�
 login.error.mfaFailed=楠岃瘉澶辫触
 login.error.requestCodeFailed=鑾峰彇璇锋眰鐮佸け璐�
@@ -99,6 +105,14 @@
 response.user.notFound=璐﹀彿涓嶅瓨鍦�
 response.user.disabled=璐﹀彿宸茶绂佺敤
 response.user.passwordMismatch=瀵嗙爜閿欒
+response.user.passkeySecureContextRequired=閫氳瀵嗛挜浠呮敮鎸佸湪 HTTPS 鎴� localhost 鐜涓嬩娇鐢�
+response.user.passkeyNotBound=褰撳墠璐﹀彿灏氭湭缁戝畾閫氳瀵嗛挜
+response.user.passkeyTicketExpired=閫氳瀵嗛挜鐧诲綍绁ㄦ嵁宸插け鏁堬紝璇烽噸鏂板彂璧�
+response.user.passkeyCounterMismatch=閫氳瀵嗛挜绛惧悕璁℃暟寮傚父锛岃閲嶆柊缁戝畾鍚庡啀璇�
+response.user.passkeyVerifyFailed=閫氳瀵嗛挜楠岃瘉澶辫触
+response.user.passkeyAlreadyBound=褰撳墠璐﹀彿宸茬粦瀹氶�氳瀵嗛挜锛屽闇�鏇存崲璇峰厛瑙g粦
+response.user.passkeyCredentialExists=璇ラ�氳瀵嗛挜宸茬粦瀹氬叾浠栬处鍙�
+response.user.passkeyRegisterFailed=閫氳瀵嗛挜缁戝畾澶辫触
 response.user.oldPasswordMismatch=褰撳墠瀵嗙爜閿欒
 response.user.mfaNotAllowed=褰撳墠璐﹀彿鏈紑閫歁FA浣跨敤鏉冮檺
 response.user.mfaNotEnabled=褰撳墠璐﹀彿鏈惎鐢∕FA
diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml
index e1d5596..8a8c580 100644
--- a/src/main/resources/mapper/UserMapper.xml
+++ b/src/main/resources/mapper/UserMapper.xml
@@ -12,16 +12,24 @@
         <result column="mfa_allow" property="mfaAllow" />
         <result column="mfa_enabled" property="mfaEnabled" />
         <result column="mfa_bound_time" property="mfaBoundTime" />
+        <result column="passkey_name" property="passkeyName" />
+        <result column="passkey_bound_time" property="passkeyBoundTime" />
+        <result column="passkey_last_used_time" property="passkeyLastUsedTime" />
         <result column="role_id" property="roleId" />
         <result column="create_time" property="createTime" />
         <result column="status" property="status" />
     </resultMap>
 
-    <resultMap id="MfaResultMap" type="com.zy.system.entity.User" extends="BaseResultMap">
+    <resultMap id="SecurityResultMap" type="com.zy.system.entity.User" extends="BaseResultMap">
         <result column="mfa_secret" property="mfaSecret" />
+        <result column="passkey_credential_id" property="passkeyCredentialId" />
+        <result column="passkey_public_key" property="passkeyPublicKey" />
+        <result column="passkey_algorithm" property="passkeyAlgorithm" />
+        <result column="passkey_sign_count" property="passkeySignCount" />
+        <result column="passkey_transports" property="passkeyTransports" />
     </resultMap>
 
-    <select id="selectByMobileWithMfa" resultMap="MfaResultMap">
+    <select id="selectByMobileWithSecurity" resultMap="SecurityResultMap">
         select
             id,
             host_id,
@@ -32,6 +40,14 @@
             mfa_enabled,
             mfa_secret,
             mfa_bound_time,
+            passkey_name,
+            passkey_credential_id,
+            passkey_public_key,
+            passkey_algorithm,
+            passkey_sign_count,
+            passkey_transports,
+            passkey_bound_time,
+            passkey_last_used_time,
             role_id,
             create_time,
             status
@@ -40,7 +56,7 @@
         limit 1
     </select>
 
-    <select id="selectByIdWithMfa" resultMap="MfaResultMap">
+    <select id="selectByIdWithSecurity" resultMap="SecurityResultMap">
         select
             id,
             host_id,
@@ -51,6 +67,14 @@
             mfa_enabled,
             mfa_secret,
             mfa_bound_time,
+            passkey_name,
+            passkey_credential_id,
+            passkey_public_key,
+            passkey_algorithm,
+            passkey_sign_count,
+            passkey_transports,
+            passkey_bound_time,
+            passkey_last_used_time,
             role_id,
             create_time,
             status
@@ -58,4 +82,31 @@
         where id = #{id}
         limit 1
     </select>
+
+    <select id="selectByPasskeyCredentialId" resultMap="SecurityResultMap">
+        select
+            id,
+            host_id,
+            username,
+            mobile,
+            password,
+            mfa_allow,
+            mfa_enabled,
+            mfa_secret,
+            mfa_bound_time,
+            passkey_name,
+            passkey_credential_id,
+            passkey_public_key,
+            passkey_algorithm,
+            passkey_sign_count,
+            passkey_transports,
+            passkey_bound_time,
+            passkey_last_used_time,
+            role_id,
+            create_time,
+            status
+        from sys_user
+        where passkey_credential_id = #{credentialId}
+        limit 1
+    </select>
 </mapper>
diff --git a/src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql b/src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql
new file mode 100644
index 0000000..a666be3
--- /dev/null
+++ b/src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql
@@ -0,0 +1,26 @@
+-- 鐢ㄩ�旓細鏀寔璐﹀彿缁戝畾鍗曚釜閫氳瀵嗛挜骞堕�氳繃閫氳瀵嗛挜鐧诲綍
+-- 閫傜敤琛細sys_user
+
+SET @table_exists := (
+    SELECT COUNT(*)
+    FROM information_schema.tables
+    WHERE table_schema = DATABASE()
+      AND table_name = 'sys_user'
+);
+
+SET @sql := IF(@table_exists = 0,
+  'SELECT ''sys_user not found''',
+  'ALTER TABLE sys_user
+      ADD COLUMN passkey_name VARCHAR(100) NULL COMMENT ''閫氳瀵嗛挜鍚嶇О'' AFTER mfa_bound_time,
+      ADD COLUMN passkey_credential_id VARCHAR(255) NULL COMMENT ''閫氳瀵嗛挜鍑瘉ID'' AFTER passkey_name,
+      ADD COLUMN passkey_public_key TEXT NULL COMMENT ''閫氳瀵嗛挜鍏挜SPKI'' AFTER passkey_credential_id,
+      ADD COLUMN passkey_algorithm INT NULL COMMENT ''閫氳瀵嗛挜绠楁硶'' AFTER passkey_public_key,
+      ADD COLUMN passkey_sign_count BIGINT NOT NULL DEFAULT 0 COMMENT ''閫氳瀵嗛挜绛惧悕璁℃暟鍣�'' AFTER passkey_algorithm,
+      ADD COLUMN passkey_transports VARCHAR(255) NULL COMMENT ''閫氳瀵嗛挜浼犺緭鏂瑰紡'' AFTER passkey_sign_count,
+      ADD COLUMN passkey_bound_time DATETIME NULL COMMENT ''閫氳瀵嗛挜缁戝畾鏃堕棿'' AFTER passkey_transports,
+      ADD COLUMN passkey_last_used_time DATETIME NULL COMMENT ''閫氳瀵嗛挜鏈�杩戜娇鐢ㄦ椂闂�'' AFTER passkey_bound_time'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/src/main/webapp/static/js/detail/detail.js b/src/main/webapp/static/js/detail/detail.js
index 0e041d7..5a80ca2 100644
--- a/src/main/webapp/static/js/detail/detail.js
+++ b/src/main/webapp/static/js/detail/detail.js
@@ -19,6 +19,9 @@
                 mfaDialogMode: "enable",
                 mfaSetupLoading: false,
                 mfaSubmitting: false,
+                passkeyDialogVisible: false,
+                passkeyDialogMode: "register",
+                passkeySubmitting: false,
                 form: {
                     id: "",
                     roleName: "",
@@ -30,7 +33,12 @@
                     mfaEnabled: 0,
                     mfaEnabled$: "鍚�",
                     mfaBoundTime$: "",
-                    mfaMaskedSecret: ""
+                    mfaMaskedSecret: "",
+                    passkeyBound: false,
+                    passkeyName: "",
+                    passkeyBoundTime$: "",
+                    passkeyLastUsedTime$: "",
+                    passkeyTransports: ""
                 },
                 passwordForm: {
                     oldPassword: "",
@@ -45,6 +53,10 @@
                     secret: "",
                     qrCode: "",
                     otpAuth: ""
+                },
+                passkeyForm: {
+                    name: "",
+                    currentPassword: ""
                 },
                 rules: {
                     username: [
@@ -109,6 +121,11 @@
                             },
                             trigger: "blur"
                         }
+                    ]
+                },
+                passkeyRules: {
+                    currentPassword: [
+                        { required: true, message: "璇疯緭鍏ュ綋鍓嶅瘑鐮�", trigger: "blur" }
                     ]
                 }
             };
@@ -314,6 +331,164 @@
                     }
                 });
             },
+            openPasskeyRegisterDialog: function () {
+                if (!window.WCS_WEBAUTHN || !window.WCS_WEBAUTHN.isSupported()) {
+                    this.$message.error(this.resolvePasskeyErrorMessage({ message: window.isSecureContext ? "not-supported" : "secure-context" }, "褰撳墠鐜涓嶆敮鎸侀�氳瀵嗛挜"));
+                    return;
+                }
+                this.passkeyDialogMode = "register";
+                this.resetPasskeyDialogState();
+                this.passkeyDialogVisible = true;
+            },
+            openPasskeyRemoveDialog: function () {
+                this.passkeyDialogMode = "remove";
+                this.resetPasskeyDialogState();
+                this.passkeyDialogVisible = true;
+            },
+            closePasskeyDialog: function () {
+                this.passkeyDialogVisible = false;
+                this.resetPasskeyDialogState();
+            },
+            resetPasskeyDialogState: function () {
+                this.passkeySubmitting = false;
+                this.passkeyForm = {
+                    name: "",
+                    currentPassword: ""
+                };
+                if (this.$refs.passkeyForm) {
+                    this.$refs.passkeyForm.clearValidate();
+                }
+            },
+            handlePasskeySubmit: function () {
+                var vm = this;
+                if (!vm.$refs.passkeyForm) {
+                    return;
+                }
+                vm.$refs.passkeyForm.validate(function (valid) {
+                    if (!valid) {
+                        return false;
+                    }
+                    if (vm.passkeyDialogMode === "register") {
+                        vm.submitPasskeyRegister();
+                    } else {
+                        vm.submitPasskeyRemove();
+                    }
+                    return true;
+                });
+            },
+            submitPasskeyRegister: function () {
+                var vm = this;
+                if (!window.WCS_WEBAUTHN || !window.WCS_WEBAUTHN.isSupported()) {
+                    vm.$message.error(vm.resolvePasskeyErrorMessage({ message: window.isSecureContext ? "not-supported" : "secure-context" }, "褰撳墠鐜涓嶆敮鎸侀�氳瀵嗛挜"));
+                    return;
+                }
+                vm.passkeySubmitting = true;
+                $.ajax({
+                    url: baseUrl + "/user/passkey/register/options/auth",
+                    headers: { token: localStorage.getItem("token") },
+                    method: "POST",
+                    success: function (res) {
+                        if (handleForbidden(res)) {
+                            return;
+                        }
+                        if (Number(res.code) !== 200) {
+                            vm.passkeySubmitting = false;
+                            vm.$message.error(res.msg || "閫氳瀵嗛挜閰嶇疆鍔犺浇澶辫触");
+                            return;
+                        }
+                        vm.executePasskeyRegister(res.data || {});
+                    },
+                    error: function () {
+                        vm.passkeySubmitting = false;
+                        vm.$message.error("閫氳瀵嗛挜閰嶇疆鍔犺浇澶辫触");
+                    }
+                });
+            },
+            executePasskeyRegister: function (optionsPayload) {
+                var vm = this;
+                window.WCS_WEBAUTHN.register(optionsPayload).then(function (credential) {
+                    $.ajax({
+                        url: baseUrl + "/user/passkey/register/finish/auth",
+                        headers: { token: localStorage.getItem("token") },
+                        data: {
+                            ticket: optionsPayload.ticket,
+                            currentPassword: hex_md5(vm.passkeyForm.currentPassword),
+                            name: vm.passkeyForm.name,
+                            credentialId: credential.credentialId,
+                            clientDataJSON: credential.clientDataJSON,
+                            authenticatorData: credential.authenticatorData,
+                            publicKey: credential.publicKey,
+                            publicKeyAlgorithm: credential.publicKeyAlgorithm,
+                            transports: credential.transports
+                        },
+                        method: "POST",
+                        success: function (res) {
+                            if (handleForbidden(res)) {
+                                return;
+                            }
+                            if (Number(res.code) !== 200) {
+                                vm.$message.error(res.msg || "閫氳瀵嗛挜缁戝畾澶辫触");
+                                return;
+                            }
+                            vm.$message.success("閫氳瀵嗛挜宸茬粦瀹�");
+                            vm.closePasskeyDialog();
+                            vm.loadDetail();
+                        },
+                        error: function () {
+                            vm.$message.error("閫氳瀵嗛挜缁戝畾澶辫触");
+                        },
+                        complete: function () {
+                            vm.passkeySubmitting = false;
+                        }
+                    });
+                }).catch(function (err) {
+                    vm.passkeySubmitting = false;
+                    vm.$message.error(vm.resolvePasskeyErrorMessage(err, "閫氳瀵嗛挜缁戝畾澶辫触"));
+                });
+            },
+            submitPasskeyRemove: function () {
+                var vm = this;
+                vm.passkeySubmitting = true;
+                $.ajax({
+                    url: baseUrl + "/user/passkey/remove/auth",
+                    headers: { token: localStorage.getItem("token") },
+                    data: {
+                        currentPassword: hex_md5(vm.passkeyForm.currentPassword)
+                    },
+                    method: "POST",
+                    success: function (res) {
+                        if (handleForbidden(res)) {
+                            return;
+                        }
+                        if (Number(res.code) !== 200) {
+                            vm.$message.error(res.msg || "閫氳瀵嗛挜瑙g粦澶辫触");
+                            return;
+                        }
+                        vm.$message.success("閫氳瀵嗛挜宸茶В缁�");
+                        vm.closePasskeyDialog();
+                        vm.loadDetail();
+                    },
+                    error: function () {
+                        vm.$message.error("閫氳瀵嗛挜瑙g粦澶辫触");
+                    },
+                    complete: function () {
+                        vm.passkeySubmitting = false;
+                    }
+                });
+            },
+            resolvePasskeyErrorMessage: function (err, fallback) {
+                var message = err && err.message ? String(err.message) : "";
+                if (message === "secure-context") {
+                    return "閫氳瀵嗛挜浠呮敮鎸佸湪 HTTPS 鎴� localhost 鐜涓嬩娇鐢�";
+                }
+                if (message === "not-supported" || message === "extension-unsupported" || message === "public-key-missing") {
+                    return "褰撳墠娴忚鍣ㄤ笉鏀寔閫氳瀵嗛挜锛岃浣跨敤鏈�鏂扮増 Chrome銆丒dge 鎴� Safari";
+                }
+                if (err && err.name === "NotAllowedError") {
+                    return "宸插彇娑堥�氳瀵嗛挜鎿嶄綔鎴栭獙璇佽秴鏃�";
+                }
+                return fallback || "閫氳瀵嗛挜鎿嶄綔澶辫触";
+            },
             copySecret: function () {
                 var vm = this;
                 var text = vm.mfaSetup.secret || "";
diff --git a/src/main/webapp/static/js/login/login.js b/src/main/webapp/static/js/login/login.js
index 9a7b1d0..2fb1a1b 100644
--- a/src/main/webapp/static/js/login/login.js
+++ b/src/main/webapp/static/js/login/login.js
@@ -11,6 +11,7 @@
                 localeOptions: [],
                 currentLocale: "zh-CN",
                 loginLoading: false,
+                passkeyLoading: false,
                 mfaLoading: false,
                 toolsDialogVisible: false,
                 textDialogVisible: false,
@@ -152,6 +153,33 @@
                     return true;
                 });
             },
+            handlePasskeyLogin: function () {
+                var vm = this;
+                if (!window.WCS_WEBAUTHN || !window.WCS_WEBAUTHN.isSupported()) {
+                    vm.$message.error(vm.resolvePasskeyErrorMessage({ message: window.isSecureContext ? "not-supported" : "secure-context" }, "login.error.passkeyOptionsFailed", "鑾峰彇閫氳瀵嗛挜鐧诲綍鍙傛暟澶辫触"));
+                    return;
+                }
+                vm.passkeyLoading = true;
+                ajaxJson({
+                    url: baseUrl + "/login/passkey/options.action",
+                    data: {
+                        mobile: vm.loginForm.mobile
+                    },
+                    method: "POST",
+                    success: function (res) {
+                        if (Number(res.code) !== 200) {
+                            vm.passkeyLoading = false;
+                            vm.$message.error(res.msg || vm.text("login.error.passkeyOptionsFailed", "鑾峰彇閫氳瀵嗛挜鐧诲綍鍙傛暟澶辫触"));
+                            return;
+                        }
+                        vm.executePasskeyLogin(res.data || {});
+                    },
+                    error: function () {
+                        vm.passkeyLoading = false;
+                        vm.$message.error(vm.text("login.error.passkeyOptionsFailed", "鑾峰彇閫氳瀵嗛挜鐧诲綍鍙傛暟澶辫触"));
+                    }
+                });
+            },
             submitLogin: function () {
                 var vm = this;
                 vm.loginLoading = true;
@@ -250,12 +278,57 @@
                     }
                 });
             },
+            executePasskeyLogin: function (payload) {
+                var vm = this;
+                window.WCS_WEBAUTHN.authenticate(payload).then(function (assertion) {
+                    ajaxJson({
+                        url: baseUrl + "/login/passkey/verify.action",
+                        data: {
+                            ticket: payload.ticket,
+                            credentialId: assertion.credentialId,
+                            clientDataJSON: assertion.clientDataJSON,
+                            authenticatorData: assertion.authenticatorData,
+                            signature: assertion.signature
+                        },
+                        method: "POST",
+                        success: function (res) {
+                            if (Number(res.code) === 200) {
+                                vm.finishLogin(res.data || {});
+                                return;
+                            }
+                            vm.$message.error(res.msg || vm.text("login.error.passkeyVerifyFailed", "閫氳瀵嗛挜楠岃瘉澶辫触"));
+                        },
+                        error: function () {
+                            vm.$message.error(vm.text("login.error.passkeyVerifyFailed", "閫氳瀵嗛挜楠岃瘉澶辫触"));
+                        },
+                        complete: function () {
+                            vm.passkeyLoading = false;
+                        }
+                    });
+                }).catch(function (err) {
+                    vm.passkeyLoading = false;
+                    vm.$message.error(vm.resolvePasskeyErrorMessage(err, "login.error.passkeyVerifyFailed", "閫氳瀵嗛挜楠岃瘉澶辫触"));
+                });
+            },
             finishLogin: function (payload) {
                 localStorage.setItem("token", payload.token || "");
                 localStorage.setItem("username", payload.username || this.loginForm.mobile || "");
                 this.closeMfaDialog();
                 window.location.href = "index.html";
             },
+            resolvePasskeyErrorMessage: function (err, fallbackKey, fallbackText) {
+                var message = err && err.message ? String(err.message) : "";
+                if (message === "secure-context") {
+                    return this.text("login.passkey.secureContext", "閫氳瀵嗛挜浠呮敮鎸佸湪 HTTPS 鎴� localhost 鐜涓嬩娇鐢�");
+                }
+                if (message === "not-supported" || message === "extension-unsupported" || message === "public-key-missing") {
+                    return this.text("login.passkey.browserUnsupported", "褰撳墠娴忚鍣ㄤ笉鏀寔閫氳瀵嗛挜锛岃浣跨敤鏈�鏂扮増 Chrome銆丒dge 鎴� Safari");
+                }
+                if (err && err.name === "NotAllowedError") {
+                    return this.text(fallbackKey, fallbackText);
+                }
+                return this.text(fallbackKey, fallbackText);
+            },
             openTextDialog: function (title, label, text, tip) {
                 var pretty = "";
                 try {
diff --git a/src/main/webapp/static/js/webauthn-utils.js b/src/main/webapp/static/js/webauthn-utils.js
new file mode 100644
index 0000000..a54eb60
--- /dev/null
+++ b/src/main/webapp/static/js/webauthn-utils.js
@@ -0,0 +1,141 @@
+(function (window) {
+    "use strict";
+
+    function base64UrlToArrayBuffer(base64Url) {
+        var value = String(base64Url || "").replace(/-/g, "+").replace(/_/g, "/");
+        var padding = value.length % 4;
+        if (padding) {
+            value += new Array(5 - padding).join("=");
+        }
+        var binary = window.atob(value);
+        var bytes = new Uint8Array(binary.length);
+        for (var i = 0; i < binary.length; i++) {
+            bytes[i] = binary.charCodeAt(i);
+        }
+        return bytes.buffer;
+    }
+
+    function arrayBufferToBase64Url(buffer) {
+        var bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer || []);
+        var binary = "";
+        for (var i = 0; i < bytes.length; i++) {
+            binary += String.fromCharCode(bytes[i]);
+        }
+        return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+    }
+
+    function normalizeArray(value) {
+        return Array.isArray(value) ? value : [];
+    }
+
+    function toCreationOptions(payload) {
+        var publicKey = {
+            challenge: base64UrlToArrayBuffer(payload.challenge),
+            rp: {
+                id: payload.rpId,
+                name: payload.rpName || "WCS"
+            },
+            user: {
+                id: base64UrlToArrayBuffer(payload.userId),
+                name: payload.userName,
+                displayName: payload.userDisplayName || payload.userName
+            },
+            pubKeyCredParams: normalizeArray(payload.pubKeyCredParams),
+            timeout: Number(payload.timeout || 60000),
+            attestation: payload.attestation || "none",
+            authenticatorSelection: payload.authenticatorSelection || {
+                residentKey: "preferred",
+                userVerification: "required"
+            }
+        };
+        var excludeCredentials = normalizeArray(payload.excludeCredentials).map(function (item) {
+            return {
+                type: item.type || "public-key",
+                id: base64UrlToArrayBuffer(item.id),
+                transports: normalizeArray(item.transports)
+            };
+        });
+        if (excludeCredentials.length) {
+            publicKey.excludeCredentials = excludeCredentials;
+        }
+        return { publicKey: publicKey };
+    }
+
+    function toRequestOptions(payload) {
+        var publicKey = {
+            challenge: base64UrlToArrayBuffer(payload.challenge),
+            rpId: payload.rpId,
+            timeout: Number(payload.timeout || 60000),
+            userVerification: payload.userVerification || "required"
+        };
+        var allowCredentials = normalizeArray(payload.allowCredentials).map(function (item) {
+            return {
+                type: item.type || "public-key",
+                id: base64UrlToArrayBuffer(item.id),
+                transports: normalizeArray(item.transports)
+            };
+        });
+        if (allowCredentials.length) {
+            publicKey.allowCredentials = allowCredentials;
+        }
+        return { publicKey: publicKey };
+    }
+
+    function ensureSupported() {
+        if (!window.isSecureContext) {
+            throw new Error("secure-context");
+        }
+        if (!window.PublicKeyCredential || !window.navigator || !window.navigator.credentials) {
+            throw new Error("not-supported");
+        }
+    }
+
+    async function register(payload) {
+        ensureSupported();
+        var credential = await window.navigator.credentials.create(toCreationOptions(payload));
+        if (!credential || !credential.response) {
+            throw new Error("create-empty");
+        }
+        var response = credential.response;
+        if (typeof response.getPublicKey !== "function" || typeof response.getAuthenticatorData !== "function") {
+            throw new Error("extension-unsupported");
+        }
+        var publicKey = response.getPublicKey();
+        var authenticatorData = response.getAuthenticatorData();
+        if (!publicKey || !authenticatorData) {
+            throw new Error("public-key-missing");
+        }
+        return {
+            credentialId: arrayBufferToBase64Url(credential.rawId),
+            clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
+            authenticatorData: arrayBufferToBase64Url(authenticatorData),
+            publicKey: arrayBufferToBase64Url(publicKey),
+            publicKeyAlgorithm: response.getPublicKeyAlgorithm(),
+            transports: JSON.stringify(typeof response.getTransports === "function" ? response.getTransports() || [] : [])
+        };
+    }
+
+    async function authenticate(payload) {
+        ensureSupported();
+        var credential = await window.navigator.credentials.get(toRequestOptions(payload));
+        if (!credential || !credential.response) {
+            throw new Error("get-empty");
+        }
+        var response = credential.response;
+        return {
+            credentialId: arrayBufferToBase64Url(credential.rawId),
+            clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
+            authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
+            signature: arrayBufferToBase64Url(response.signature),
+            userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : ""
+        };
+    }
+
+    window.WCS_WEBAUTHN = {
+        isSupported: function () {
+            return !!(window.isSecureContext && window.PublicKeyCredential && window.navigator && window.navigator.credentials);
+        },
+        register: register,
+        authenticate: authenticate
+    };
+})(window);
diff --git a/src/main/webapp/views/detail.html b/src/main/webapp/views/detail.html
index 301d6b2..bbed975 100644
--- a/src/main/webapp/views/detail.html
+++ b/src/main/webapp/views/detail.html
@@ -394,6 +394,47 @@
                                 </div>
                             </el-form-item>
                         </el-col>
+                        <el-col :xs="24">
+                            <el-form-item label="閫氳瀵嗛挜">
+                                <div class="mfa-panel">
+                                    <div class="mfa-head">
+                                        <div class="mfa-title">璁惧鐢熺墿璇嗗埆 / 瀹夊叏瀵嗛挜鐧诲綍</div>
+                                        <div class="mfa-actions">
+                                            <el-button
+                                                v-if="!form.passkeyBound"
+                                                type="primary"
+                                                plain
+                                                icon="el-icon-key"
+                                                @click="openPasskeyRegisterDialog">缁戝畾閫氳瀵嗛挜</el-button>
+                                            <el-button
+                                                v-else
+                                                type="danger"
+                                                plain
+                                                icon="el-icon-delete"
+                                                @click="openPasskeyRemoveDialog">瑙g粦閫氳瀵嗛挜</el-button>
+                                        </div>
+                                    </div>
+                                    <div class="mfa-meta">
+                                        <div class="mfa-meta-item">
+                                            <div class="mfa-meta-label">缁戝畾鐘舵��</div>
+                                            <div class="mfa-meta-value">{{ form.passkeyBound ? '宸茬粦瀹�' : '鏈粦瀹�' }}</div>
+                                        </div>
+                                        <div class="mfa-meta-item">
+                                            <div class="mfa-meta-label">鏄剧ず鍚嶇О</div>
+                                            <div class="mfa-meta-value">{{ form.passkeyName || '--' }}</div>
+                                        </div>
+                                        <div class="mfa-meta-item">
+                                            <div class="mfa-meta-label">缁戝畾鏃堕棿</div>
+                                            <div class="mfa-meta-value">{{ form.passkeyBoundTime$ || '--' }}</div>
+                                        </div>
+                                    </div>
+                                    <div class="mfa-tip">
+                                        <span v-if="form.passkeyBound">鏈�杩戜娇鐢細{{ form.passkeyLastUsedTime$ || '--' }}</span>
+                                        <span v-else>缁戝畾鍚庡彲鐩存帴浣跨敤璁惧鎸囩汗銆佷汉鑴告垨瀹夊叏瀵嗛挜鐧诲綍銆傝鑳藉姏瑕佹眰娴忚鍣ㄦ敮鎸侀�氳瀵嗛挜锛屼笖绯荤粺浠� HTTPS 鎴� localhost 鎵撳紑銆�</span>
+                                    </div>
+                                </div>
+                            </el-form-item>
+                        </el-col>
                     </el-row>
                     <div class="footer-bar">
                         <el-button type="primary" :loading="saving" @click="handleSave">纭淇敼</el-button>
@@ -476,12 +517,46 @@
             </div>
         </el-form>
     </el-dialog>
+
+    <el-dialog
+        class="mfa-dialog"
+        :title="passkeyDialogMode === 'register' ? '缁戝畾閫氳瀵嗛挜' : '瑙g粦閫氳瀵嗛挜'"
+        :visible.sync="passkeyDialogVisible"
+        width="520px"
+        :close-on-click-modal="false"
+        @close="closePasskeyDialog"
+        append-to-body>
+        <div class="mfa-setup">
+            <div v-if="passkeyDialogMode === 'register'" class="mfa-setup-tip">缁戝畾鏃朵細寮瑰嚭绯荤粺绾ц韩浠介獙璇佺獥鍙o紝璇蜂娇鐢ㄥ綋鍓嶈澶囩殑浜鸿劯銆佹寚绾广�丳IN 鎴栧畨鍏ㄥ瘑閽ュ畬鎴愮‘璁ゃ�傝嫢娴忚鍣ㄦ垨鐜涓嶆敮鎸侊紝鏃犳硶鍚敤璇ヨ兘鍔涖��</div>
+            <div v-else class="mfa-setup-tip">瑙g粦鍓嶈杈撳叆褰撳墠瀵嗙爜纭銆傝В缁戝悗灏嗕笉鑳藉啀鐢ㄥ綋鍓嶉�氳瀵嗛挜鐩存帴鐧诲綍銆�</div>
+        </div>
+        <el-form
+            ref="passkeyForm"
+            class="password-form"
+            :model="passkeyForm"
+            :rules="passkeyRules"
+            label-width="112px"
+            size="small"
+            @submit.native.prevent>
+            <el-form-item v-if="passkeyDialogMode === 'register'" label="鏄剧ず鍚嶇О" prop="name">
+                <el-input v-model.trim="passkeyForm.name" maxlength="100" autocomplete="off" placeholder="渚嬪锛氬姙鍏鐢佃剳"></el-input>
+            </el-form-item>
+            <el-form-item label="褰撳墠瀵嗙爜" prop="currentPassword">
+                <el-input v-model="passkeyForm.currentPassword" type="password" show-password autocomplete="off"></el-input>
+            </el-form-item>
+            <div class="mfa-footer">
+                <el-button @click="closePasskeyDialog">鍏抽棴</el-button>
+                <el-button type="primary" :loading="passkeySubmitting" @click="handlePasskeySubmit">淇濆瓨</el-button>
+            </div>
+        </el-form>
+    </el-dialog>
 </div>
 </body>
 <script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
 <script type="text/javascript" src="../static/js/tools/md5.js"></script>
 <script type="text/javascript" src="../static/js/common.js"></script>
+<script type="text/javascript" src="../static/js/webauthn-utils.js"></script>
 <script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
 <script type="text/javascript" src="../static/vue/element/element.js"></script>
-<script type="text/javascript" src="../static/js/detail/detail.js?v=20260311_detail_mfa"></script>
+<script type="text/javascript" src="../static/js/detail/detail.js?v=20260311_detail_passkey"></script>
 </html>
diff --git a/src/main/webapp/views/login.html b/src/main/webapp/views/login.html
index 47918db..0bc4ab7 100644
--- a/src/main/webapp/views/login.html
+++ b/src/main/webapp/views/login.html
@@ -260,15 +260,43 @@
         }
 
         .login-submit {
-            width: 100%;
             display: inline-flex;
             align-items: center;
             justify-content: center;
             font-size: 16px;
             font-weight: 600;
             border-radius: 16px;
-            margin-top: 6px;
             box-shadow: 0 14px 24px rgba(46, 115, 223, 0.28);
+        }
+
+        .login-actions {
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+            margin-top: 6px;
+        }
+
+        .login-actions .el-button {
+            width: 100%;
+            margin-left: 0;
+            box-sizing: border-box;
+        }
+
+        .login-passkey {
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 16px;
+            border-color: rgba(71, 110, 162, 0.24);
+            color: #26496a;
+            background: rgba(245, 249, 255, 0.96);
+        }
+
+        .login-passkey-tip {
+            margin-top: 12px;
+            color: #7b8c9d;
+            font-size: 12px;
+            line-height: 1.7;
         }
 
         .tools-dialog .el-dialog,
@@ -495,9 +523,17 @@
                             <i slot="prefix" class="el-input__icon el-icon-lock"></i>
                         </el-input>
                     </el-form-item>
-                    <el-button class="login-submit" type="primary" :loading="loginLoading" @click="handleLogin">
-                        {{ text('login.submit', '鐧诲綍') }}
-                    </el-button>
+                    <div class="login-actions">
+                        <el-button class="login-submit" type="primary" :loading="loginLoading" @click="handleLogin">
+                            {{ text('login.submit', '鐧诲綍') }}
+                        </el-button>
+                        <el-button class="login-passkey" plain :loading="passkeyLoading" @click="handlePasskeyLogin">
+                            {{ text('login.passkey.submit', '閫氳瀵嗛挜鐧诲綍') }}
+                        </el-button>
+                    </div>
+                    <div class="login-passkey-tip">
+                        {{ text('login.passkey.tip', '鏀寔浣跨敤璁惧鐢熺墿璇嗗埆鎴栧畨鍏ㄥ瘑閽ョ櫥褰曘�傚彲鍏堣緭鍏ヨ处鍙风缉灏忓嚟璇佽寖鍥达紝鐣欑┖鍒欏皾璇曞彂鐜板紡鐧诲綍銆�') }}
+                    </div>
                 </el-form>
             </div>
         </section>
@@ -567,7 +603,8 @@
 <script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
 <script type="text/javascript" src="../static/js/tools/md5.js"></script>
 <script type="text/javascript" src="../static/js/common.js"></script>
+<script type="text/javascript" src="../static/js/webauthn-utils.js"></script>
 <script type="text/javascript" src="../static/vue/js/vue.min.js"></script>
 <script type="text/javascript" src="../static/vue/element/element.js"></script>
-<script type="text/javascript" src="../static/js/login/login.js?v=20260311_login_mfa"></script>
+<script type="text/javascript" src="../static/js/login/login.js?v=20260311_login_passkey"></script>
 </html>

--
Gitblit v1.9.1