| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| | |
| | | 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.*; |
| | |
| | | private I18nMessageService i18nMessageService; |
| | | @Autowired |
| | | private MfaLoginTicketManager mfaLoginTicketManager; |
| | | @Autowired |
| | | private PasskeyChallengeManager passkeyChallengeManager; |
| | | |
| | | @RequestMapping("/login.action") |
| | | @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "登录") |
| | |
| | | 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")); |
| | | } |
| | |
| | | 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")); |
| | |
| | | } |
| | | 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") |
| | |
| | | @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(); |
| | | } |
| | |
| | | @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")); |
| | | } |
| | |
| | | @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")); |
| | | } |
| | |
| | | @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")); |
| | | } |
| | |
| | | .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(); |
| | | } |
| | | |
| | |
| | | && !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()); |
| | |
| | | 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) { |
| | |
| | | .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()); |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | } |
| | | user.setPassword(null); |
| | | user.setMfaSecret(null); |
| | | user.setPasskeyCredentialId(null); |
| | | user.setPasskeyPublicKey(null); |
| | | user.setPasskeyAlgorithm(null); |
| | | user.setPasskeySignCount(null); |
| | | user.setPasskeyTransports(null); |
| | | } |
| | | |
| | | } |
| | |
| | | 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") |
| | |
| | | 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; |
| | | } |
| | |
| | | @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); |
| | | } |
| | |
| | | |
| | | 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); |
| | | } |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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. |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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=优先使用“获取请求码”和“一键激活”完成许可证申请与激活。 |
| | |
| | | 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=获取请求码失败 |
| | |
| | | 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 |
| | |
| | | <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, |
| | |
| | | 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 |
| | |
| | | limit 1 |
| | | </select> |
| | | |
| | | <select id="selectByIdWithMfa" resultMap="MfaResultMap"> |
| | | <select id="selectByIdWithSecurity" resultMap="SecurityResultMap"> |
| | | select |
| | | id, |
| | | host_id, |
| | |
| | | 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 |
| | |
| | | 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> |
| New file |
| | |
| | | -- 用途:支持账号绑定单个通行密钥并通过通行密钥登录 |
| | | -- 适用表: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; |
| | |
| | | mfaDialogMode: "enable", |
| | | mfaSetupLoading: false, |
| | | mfaSubmitting: false, |
| | | passkeyDialogVisible: false, |
| | | passkeyDialogMode: "register", |
| | | passkeySubmitting: false, |
| | | form: { |
| | | id: "", |
| | | roleName: "", |
| | |
| | | mfaEnabled: 0, |
| | | mfaEnabled$: "否", |
| | | mfaBoundTime$: "", |
| | | mfaMaskedSecret: "" |
| | | mfaMaskedSecret: "", |
| | | passkeyBound: false, |
| | | passkeyName: "", |
| | | passkeyBoundTime$: "", |
| | | passkeyLastUsedTime$: "", |
| | | passkeyTransports: "" |
| | | }, |
| | | passwordForm: { |
| | | oldPassword: "", |
| | |
| | | secret: "", |
| | | qrCode: "", |
| | | otpAuth: "" |
| | | }, |
| | | passkeyForm: { |
| | | name: "", |
| | | currentPassword: "" |
| | | }, |
| | | rules: { |
| | | username: [ |
| | |
| | | }, |
| | | trigger: "blur" |
| | | } |
| | | ] |
| | | }, |
| | | passkeyRules: { |
| | | currentPassword: [ |
| | | { required: true, message: "请输入当前密码", trigger: "blur" } |
| | | ] |
| | | } |
| | | }; |
| | |
| | | } |
| | | }); |
| | | }, |
| | | 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 || ""; |
| | |
| | | localeOptions: [], |
| | | currentLocale: "zh-CN", |
| | | loginLoading: false, |
| | | passkeyLoading: false, |
| | | mfaLoading: false, |
| | | toolsDialogVisible: false, |
| | | textDialogVisible: false, |
| | |
| | | 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; |
| | |
| | | } |
| | | }); |
| | | }, |
| | | 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 { |
| New file |
| | |
| | | (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); |
| | |
| | | </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> |
| | |
| | | </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> |
| | |
| | | } |
| | | |
| | | .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, |
| | |
| | | <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> |
| | |
| | | <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> |