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