#
Junjie
昨天 bd6b518aae61608ddc2d82b43ccc283dc95b9c54
#
5个文件已添加
13个文件已修改
1569 ■■■■■ 已修改文件
src/main/java/com/zy/common/auth/PasskeyChallengeManager.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/web/AuthController.java 321 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/UserController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/entity/User.java 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/mapper/UserMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/UserService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/impl/UserServiceImpl.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/en-US/messages.properties 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/zh-CN/messages.properties 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/UserMapper.xml 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/detail/detail.js 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/login/login.js 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/webauthn-utils.js 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/detail.html 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/login.html 49 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/auth/PasskeyChallengeManager.java
New file
@@ -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;
        }
    }
}
src/main/java/com/zy/common/utils/PasskeyWebAuthnUtil.java
New file
@@ -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;
        }
    }
}
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());
    }
src/main/java/com/zy/system/config/UserPasskeySchemaInitializer.java
New file
@@ -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;
    }
}
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);
    }
}
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;
    /**
     * 通行密钥公钥(SPKI 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;
    }
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);
}
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);
}
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);
    }
}
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
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、Edge 或 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=当前账号已绑定通行密钥,如需更换请先解绑
response.user.passkeyCredentialExists=该通行密钥已绑定其他账号
response.user.passkeyRegisterFailed=通行密钥绑定失败
response.user.oldPasswordMismatch=当前密码错误
response.user.mfaNotAllowed=当前账号未开通MFA使用权限
response.user.mfaNotEnabled=当前账号未启用MFA
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>
src/main/resources/sql/20260311_add_passkey_columns_to_sys_user.sql
New file
@@ -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;
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 || "通行密钥解绑失败");
                            return;
                        }
                        vm.$message.success("通行密钥已解绑");
                        vm.closePasskeyDialog();
                        vm.loadDetail();
                    },
                    error: function () {
                        vm.$message.error("通行密钥解绑失败");
                    },
                    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、Edge 或 Safari";
                }
                if (err && err.name === "NotAllowedError") {
                    return "已取消通行密钥操作或验证超时";
                }
                return fallback || "通行密钥操作失败";
            },
            copySecret: function () {
                var vm = this;
                var text = vm.mfaSetup.secret || "";
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、Edge 或 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 {
src/main/webapp/static/js/webauthn-utils.js
New file
@@ -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);
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">解绑通行密钥</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' ? '绑定通行密钥' : '解绑通行密钥'"
        :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">绑定时会弹出系统级身份验证窗口,请使用当前设备的人脸、指纹、PIN 或安全密钥完成确认。若浏览器或环境不支持,无法启用该能力。</div>
            <div v-else class="mfa-setup-tip">解绑前请输入当前密码确认。解绑后将不能再用当前通行密钥直接登录。</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>
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>