| | |
| | | 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()); |
| | | } |