| 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 MfaLoginTicketManager { |
| | | |
| | | private static final long EXPIRE_MILLIS = 5 * 60 * 1000L; |
| | | |
| | | private final SecureRandom secureRandom = new SecureRandom(); |
| | | private final ConcurrentHashMap<String, TicketHolder> holders = new ConcurrentHashMap<>(); |
| | | |
| | | public String create(Long userId) { |
| | | cleanup(); |
| | | String ticket; |
| | | do { |
| | | ticket = randomTicket(); |
| | | } while (holders.putIfAbsent(ticket, new TicketHolder(userId, System.currentTimeMillis() + EXPIRE_MILLIS)) != null); |
| | | return ticket; |
| | | } |
| | | |
| | | public Long getUserId(String ticket) { |
| | | if (Cools.isEmpty(ticket)) { |
| | | return null; |
| | | } |
| | | TicketHolder holder = holders.get(ticket); |
| | | if (holder == null) { |
| | | return null; |
| | | } |
| | | if (holder.expireAt < System.currentTimeMillis()) { |
| | | holders.remove(ticket); |
| | | return null; |
| | | } |
| | | return holder.userId; |
| | | } |
| | | |
| | | public void remove(String ticket) { |
| | | if (!Cools.isEmpty(ticket)) { |
| | | holders.remove(ticket); |
| | | } |
| | | } |
| | | |
| | | private void cleanup() { |
| | | long now = System.currentTimeMillis(); |
| | | for (Map.Entry<String, TicketHolder> 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 static final class TicketHolder { |
| | | private final Long userId; |
| | | private final long expireAt; |
| | | |
| | | private TicketHolder(Long userId, long expireAt) { |
| | | this.userId = userId; |
| | | this.expireAt = expireAt; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.zy.common.utils; |
| | | |
| | | import com.core.common.Cools; |
| | | |
| | | import javax.crypto.Mac; |
| | | import javax.crypto.spec.SecretKeySpec; |
| | | import java.io.ByteArrayOutputStream; |
| | | import java.net.URLEncoder; |
| | | import java.nio.ByteBuffer; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.SecureRandom; |
| | | import java.util.Locale; |
| | | |
| | | public final class MfaTotpUtil { |
| | | |
| | | private static final char[] BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray(); |
| | | private static final SecureRandom SECURE_RANDOM = new SecureRandom(); |
| | | private static final int SECRET_SIZE = 20; |
| | | private static final int OTP_DIGITS = 6; |
| | | private static final int OTP_PERIOD_SECONDS = 30; |
| | | |
| | | private MfaTotpUtil() { |
| | | } |
| | | |
| | | public static String generateSecret() { |
| | | byte[] buffer = new byte[SECRET_SIZE]; |
| | | SECURE_RANDOM.nextBytes(buffer); |
| | | return encodeBase32(buffer); |
| | | } |
| | | |
| | | public static boolean verifyCode(String secret, String code, int window) { |
| | | if (Cools.isEmpty(secret, code)) { |
| | | return false; |
| | | } |
| | | String normalizedCode = String.valueOf(code).replaceAll("\\s+", ""); |
| | | if (!normalizedCode.matches("\\d{" + OTP_DIGITS + "}")) { |
| | | return false; |
| | | } |
| | | try { |
| | | long currentStep = System.currentTimeMillis() / 1000L / OTP_PERIOD_SECONDS; |
| | | for (int offset = -window; offset <= window; offset++) { |
| | | if (normalizedCode.equals(generateCode(secret, currentStep + offset))) { |
| | | return true; |
| | | } |
| | | } |
| | | } catch (Exception ignored) { |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | public static String buildOtpAuthUri(String issuer, String account, String secret) { |
| | | String safeIssuer = Cools.isEmpty(issuer) ? "WCS" : issuer.trim(); |
| | | String safeAccount = Cools.isEmpty(account) ? "user" : account.trim(); |
| | | String label = urlEncode(safeIssuer + ":" + safeAccount); |
| | | return "otpauth://totp/" + label |
| | | + "?secret=" + secret |
| | | + "&issuer=" + urlEncode(safeIssuer) |
| | | + "&algorithm=SHA1&digits=" + OTP_DIGITS |
| | | + "&period=" + OTP_PERIOD_SECONDS; |
| | | } |
| | | |
| | | public static String maskSecret(String secret) { |
| | | if (Cools.isEmpty(secret)) { |
| | | return ""; |
| | | } |
| | | String value = String.valueOf(secret).trim(); |
| | | if (value.length() <= 8) { |
| | | return value; |
| | | } |
| | | return value.substring(0, 4) + "****" + value.substring(value.length() - 4); |
| | | } |
| | | |
| | | private static String generateCode(String secret, long step) { |
| | | try { |
| | | byte[] key = decodeBase32(secret); |
| | | byte[] data = ByteBuffer.allocate(8).putLong(step).array(); |
| | | Mac mac = Mac.getInstance("HmacSHA1"); |
| | | mac.init(new SecretKeySpec(key, "HmacSHA1")); |
| | | byte[] hash = mac.doFinal(data); |
| | | int offset = hash[hash.length - 1] & 0x0F; |
| | | int binary = ((hash[offset] & 0x7F) << 24) |
| | | | ((hash[offset + 1] & 0xFF) << 16) |
| | | | ((hash[offset + 2] & 0xFF) << 8) |
| | | | (hash[offset + 3] & 0xFF); |
| | | int otp = binary % (int) Math.pow(10, OTP_DIGITS); |
| | | return String.format(Locale.ROOT, "%0" + OTP_DIGITS + "d", otp); |
| | | } catch (Exception e) { |
| | | throw new IllegalStateException("generate totp code failed", e); |
| | | } |
| | | } |
| | | |
| | | private static String encodeBase32(byte[] data) { |
| | | StringBuilder builder = new StringBuilder((data.length * 8 + 4) / 5); |
| | | int buffer = 0; |
| | | int bitsLeft = 0; |
| | | for (byte datum : data) { |
| | | buffer = (buffer << 8) | (datum & 0xFF); |
| | | bitsLeft += 8; |
| | | while (bitsLeft >= 5) { |
| | | builder.append(BASE32_ALPHABET[(buffer >> (bitsLeft - 5)) & 0x1F]); |
| | | bitsLeft -= 5; |
| | | } |
| | | } |
| | | if (bitsLeft > 0) { |
| | | builder.append(BASE32_ALPHABET[(buffer << (5 - bitsLeft)) & 0x1F]); |
| | | } |
| | | return builder.toString(); |
| | | } |
| | | |
| | | private static byte[] decodeBase32(String value) { |
| | | String normalized = String.valueOf(value) |
| | | .trim() |
| | | .replace("=", "") |
| | | .replace(" ", "") |
| | | .replace("-", "") |
| | | .toUpperCase(Locale.ROOT); |
| | | ByteArrayOutputStream output = new ByteArrayOutputStream(); |
| | | int buffer = 0; |
| | | int bitsLeft = 0; |
| | | for (int i = 0; i < normalized.length(); i++) { |
| | | char current = normalized.charAt(i); |
| | | int index = indexOfBase32(current); |
| | | if (index < 0) { |
| | | throw new IllegalArgumentException("invalid base32 secret"); |
| | | } |
| | | buffer = (buffer << 5) | index; |
| | | bitsLeft += 5; |
| | | if (bitsLeft >= 8) { |
| | | output.write((buffer >> (bitsLeft - 8)) & 0xFF); |
| | | bitsLeft -= 8; |
| | | } |
| | | } |
| | | return output.toByteArray(); |
| | | } |
| | | |
| | | private static int indexOfBase32(char value) { |
| | | for (int i = 0; i < BASE32_ALPHABET.length; i++) { |
| | | if (BASE32_ALPHABET[i] == value) { |
| | | return i; |
| | | } |
| | | } |
| | | return -1; |
| | | } |
| | | |
| | | private static String urlEncode(String value) { |
| | | return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); |
| | | } |
| | | } |
| | |
| | | |
| | | |
| | | public static BufferedImage createImg(String content) throws WriterException { |
| | | return createImg(content, QRCODE_SIZE); |
| | | } |
| | | |
| | | public static BufferedImage createImg(String content, int size) throws WriterException { |
| | | ConcurrentHashMap hints = new ConcurrentHashMap(); |
| | | hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); |
| | | hints.put(EncodeHintType.CHARACTER_SET, CHARSET); |
| | | hints.put(EncodeHintType.MARGIN, 1); |
| | | BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, QRCODE_SIZE, QRCODE_SIZE, hints); |
| | | BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints); |
| | | int width = bitMatrix.getWidth(); |
| | | int height = bitMatrix.getHeight(); |
| | | BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); |
| | |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import com.core.annotations.ManagerAuth; |
| | | import com.core.common.Cools; |
| | | import com.core.common.R; |
| | | import com.core.exception.CoolException; |
| | | import com.zy.common.CodeRes; |
| | | import com.zy.common.auth.MfaLoginTicketManager; |
| | | 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.QrCode; |
| | | import com.zy.common.utils.RandomValidateCodeUtil; |
| | | import com.zy.system.entity.*; |
| | | import com.zy.system.service.*; |
| | |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import jakarta.servlet.http.HttpServletResponse; |
| | | import javax.imageio.ImageIO; |
| | | import java.awt.image.BufferedImage; |
| | | import java.io.ByteArrayOutputStream; |
| | | import java.util.Base64; |
| | | import java.util.*; |
| | | |
| | | /** |
| | |
| | | private LicenseTimer licenseTimer; |
| | | @Autowired |
| | | private I18nMessageService i18nMessageService; |
| | | @Autowired |
| | | private MfaLoginTicketManager mfaLoginTicketManager; |
| | | |
| | | @RequestMapping("/login.action") |
| | | @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "登录") |
| | |
| | | if (!licenseTimer.getSystemSupport()){ |
| | | return new R(20001, i18nMessageService.getMessage("response.system.licenseExpired")); |
| | | } |
| | | if (Cools.isEmpty(mobile, password)) { |
| | | return new R(10003, i18nMessageService.getMessage("response.user.passwordMismatch")); |
| | | } |
| | | if (mobile.equals("super") && password.equals(Cools.md5(superPwd))) { |
| | | Map<String, Object> res = new HashMap<>(); |
| | | res.put("username", mobile); |
| | | res.put("token", Cools.enToken(System.currentTimeMillis() + mobile, superPwd)); |
| | | return R.ok(res); |
| | | } |
| | | QueryWrapper<User> userWrapper = new QueryWrapper<>(); |
| | | userWrapper.eq("mobile", mobile); |
| | | User user = userService.getOne(userWrapper); |
| | | User user = userService.getByMobileWithMfa(mobile); |
| | | if (Cools.isEmpty(user)){ |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | |
| | | if (!user.getPassword().equals(password)){ |
| | | return new R(10003, i18nMessageService.getMessage("response.user.passwordMismatch")); |
| | | } |
| | | String token = Cools.enToken(System.currentTimeMillis() + mobile, user.getPassword()); |
| | | userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", user.getId()).eq("system_type", "WCS")); |
| | | UserLogin userLogin = new UserLogin(); |
| | | userLogin.setUserId(user.getId()); |
| | | userLogin.setToken(token); |
| | | userLogin.setSystemType("WCS"); |
| | | userLoginService.save(userLogin); |
| | | if (requiresMfa(user)) { |
| | | Map<String, Object> res = new HashMap<>(); |
| | | res.put("username", user.getUsername()); |
| | | res.put("token", token); |
| | | res.put("mfaRequired", true); |
| | | res.put("mfaTicket", mfaLoginTicketManager.create(user.getId())); |
| | | return R.ok(res); |
| | | } |
| | | return R.ok(buildLoginSuccess(user)); |
| | | } |
| | | |
| | | @RequestMapping("/login/mfa.action") |
| | | @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "MFA登录") |
| | | public R loginMfaAction(String ticket, String code) { |
| | | Long userId = mfaLoginTicketManager.getUserId(ticket); |
| | | if (userId == null) { |
| | | return new R(10004, i18nMessageService.getMessage("response.user.mfaTicketExpired")); |
| | | } |
| | | User user = userService.getByIdWithMfa(userId); |
| | | if (Cools.isEmpty(user)) { |
| | | mfaLoginTicketManager.remove(ticket); |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | | if (user.getStatus() != 1) { |
| | | mfaLoginTicketManager.remove(ticket); |
| | | return new R(10002, i18nMessageService.getMessage("response.user.disabled")); |
| | | } |
| | | if (!requiresMfa(user)) { |
| | | mfaLoginTicketManager.remove(ticket); |
| | | return new R(10005, i18nMessageService.getMessage("response.user.mfaNotEnabled")); |
| | | } |
| | | if (!MfaTotpUtil.verifyCode(user.getMfaSecret(), code, 1)) { |
| | | return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch")); |
| | | } |
| | | mfaLoginTicketManager.remove(ticket); |
| | | return R.ok(buildLoginSuccess(user)); |
| | | } |
| | | |
| | | @RequestMapping("/code/switch.action") |
| | |
| | | @RequestMapping("/user/detail/auth") |
| | | @ManagerAuth |
| | | public R userDetail(){ |
| | | return R.ok(userService.getById(getUserId())); |
| | | User user = userService.getByIdWithMfa(getUserId()); |
| | | if (Cools.isEmpty(user)) { |
| | | return R.ok(); |
| | | } |
| | | return R.ok(buildSafeUserDetail(user)); |
| | | } |
| | | |
| | | @RequestMapping("/user/mfa/setup/auth") |
| | | @ManagerAuth |
| | | public R userMfaSetup() { |
| | | User user = userService.getByIdWithMfa(getUserId()); |
| | | if (Cools.isEmpty(user)) { |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | | if (!Integer.valueOf(1).equals(user.getMfaAllow())) { |
| | | return new R(10007, i18nMessageService.getMessage("response.user.mfaNotAllowed")); |
| | | } |
| | | String secret = MfaTotpUtil.generateSecret(); |
| | | String account = !Cools.isEmpty(user.getMobile()) |
| | | ? user.getMobile() |
| | | : (!Cools.isEmpty(user.getUsername()) ? user.getUsername() : String.valueOf(user.getId())); |
| | | String otpAuth = MfaTotpUtil.buildOtpAuthUri("WCS", account, secret); |
| | | Map<String, Object> data = new HashMap<>(); |
| | | data.put("secret", secret); |
| | | data.put("otpAuth", otpAuth); |
| | | data.put("qrCode", renderQrCodeDataUri(otpAuth)); |
| | | return R.ok(data); |
| | | } |
| | | |
| | | @RequestMapping("/user/mfa/enable/auth") |
| | | @ManagerAuth |
| | | @Transactional |
| | | public R userMfaEnable(String currentPassword, String secret, String code) { |
| | | User user = userService.getByIdWithMfa(getUserId()); |
| | | if (Cools.isEmpty(user)) { |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | | if (!Integer.valueOf(1).equals(user.getMfaAllow())) { |
| | | return new R(10007, i18nMessageService.getMessage("response.user.mfaNotAllowed")); |
| | | } |
| | | if (!Cools.eq(user.getPassword(), currentPassword)) { |
| | | return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch")); |
| | | } |
| | | String normalizedSecret = normalizeSecret(secret); |
| | | if (Cools.isEmpty(normalizedSecret) || !MfaTotpUtil.verifyCode(normalizedSecret, code, 1)) { |
| | | return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch")); |
| | | } |
| | | userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>() |
| | | .eq("id", user.getId()) |
| | | .set("mfa_allow", 1) |
| | | .set("mfa_enabled", 1) |
| | | .set("mfa_secret", normalizedSecret) |
| | | .set("mfa_bound_time", new Date())); |
| | | return R.ok(); |
| | | } |
| | | |
| | | @RequestMapping("/user/mfa/disable/auth") |
| | | @ManagerAuth |
| | | @Transactional |
| | | public R userMfaDisable(String currentPassword, String code) { |
| | | User user = userService.getByIdWithMfa(getUserId()); |
| | | if (Cools.isEmpty(user)) { |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | | if (!requiresMfa(user)) { |
| | | return new R(10005, i18nMessageService.getMessage("response.user.mfaNotEnabled")); |
| | | } |
| | | if (!Cools.eq(user.getPassword(), currentPassword)) { |
| | | return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch")); |
| | | } |
| | | if (!MfaTotpUtil.verifyCode(user.getMfaSecret(), code, 1)) { |
| | | return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch")); |
| | | } |
| | | userService.update(new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<User>() |
| | | .eq("id", user.getId()) |
| | | .set("mfa_enabled", 0) |
| | | .set("mfa_secret", null) |
| | | .set("mfa_bound_time", null)); |
| | | return R.ok(); |
| | | } |
| | | |
| | | @RequestMapping("/menu/auth") |
| | |
| | | return R.ok(result); |
| | | } |
| | | |
| | | private Map<String, Object> buildLoginSuccess(User user) { |
| | | String token = Cools.enToken(System.currentTimeMillis() + user.getMobile(), user.getPassword()); |
| | | userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", user.getId()).eq("system_type", "WCS")); |
| | | UserLogin userLogin = new UserLogin(); |
| | | userLogin.setUserId(user.getId()); |
| | | userLogin.setToken(token); |
| | | userLogin.setSystemType("WCS"); |
| | | userLoginService.save(userLogin); |
| | | Map<String, Object> result = new HashMap<>(); |
| | | result.put("username", user.getUsername()); |
| | | result.put("token", token); |
| | | result.put("mfaRequired", false); |
| | | return result; |
| | | } |
| | | |
| | | private boolean requiresMfa(User user) { |
| | | return user != null |
| | | && Integer.valueOf(1).equals(user.getMfaAllow()) |
| | | && Integer.valueOf(1).equals(user.getMfaEnabled()) |
| | | && !Cools.isEmpty(user.getMfaSecret()); |
| | | } |
| | | |
| | | private Map<String, Object> buildSafeUserDetail(User user) { |
| | | Map<String, Object> result = new HashMap<>(); |
| | | result.put("id", user.getId()); |
| | | result.put("roleName", user.getRoleName()); |
| | | result.put("username", user.getUsername()); |
| | | result.put("mobile", user.getMobile()); |
| | | result.put("createTime$", user.getCreateTime$()); |
| | | result.put("mfaAllow", user.getMfaAllow()); |
| | | result.put("mfaAllow$", user.getMfaAllow$()); |
| | | result.put("mfaEnabled", user.getMfaEnabled()); |
| | | result.put("mfaEnabled$", user.getMfaEnabled$()); |
| | | result.put("mfaBoundTime$", user.getMfaBoundTime$()); |
| | | result.put("mfaMaskedSecret", MfaTotpUtil.maskSecret(user.getMfaSecret())); |
| | | return result; |
| | | } |
| | | |
| | | private String renderQrCodeDataUri(String content) { |
| | | try { |
| | | BufferedImage image = QrCode.createImg(content, 220); |
| | | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); |
| | | ImageIO.write(image, "jpg", outputStream); |
| | | return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(outputStream.toByteArray()); |
| | | } catch (Exception e) { |
| | | return ""; |
| | | } |
| | | } |
| | | |
| | | private String normalizeSecret(String secret) { |
| | | if (Cools.isEmpty(secret)) { |
| | | return ""; |
| | | } |
| | | return String.valueOf(secret) |
| | | .trim() |
| | | .replace(" ", "") |
| | | .replace("-", "") |
| | | .toUpperCase(Locale.ROOT); |
| | | } |
| | | |
| | | 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 UserMfaSchemaInitializer { |
| | | |
| | | private final DataSource dataSource; |
| | | |
| | | public UserMfaSchemaInitializer(DataSource dataSource) { |
| | | this.dataSource = dataSource; |
| | | } |
| | | |
| | | @PostConstruct |
| | | public void init() { |
| | | ensureColumn("sys_user", "mfa_allow", "INT DEFAULT 0"); |
| | | ensureColumn("sys_user", "mfa_enabled", "INT DEFAULT 0"); |
| | | ensureColumn("sys_user", "mfa_secret", "VARCHAR(128)"); |
| | | ensureColumn("sys_user", "mfa_bound_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; |
| | | } |
| | | } |
| | |
| | | |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.core.annotations.ManagerAuth; |
| | | import com.core.common.Cools; |
| | | import com.core.common.DateUtils; |
| | | import com.core.common.R; |
| | | import com.zy.common.i18n.I18nMessageService; |
| | | import com.zy.common.web.BaseController; |
| | | import com.zy.system.entity.Role; |
| | | import com.zy.system.entity.User; |
| | | import com.zy.system.entity.UserLogin; |
| | | import com.zy.system.service.RoleService; |
| | | import com.zy.system.service.UserLoginService; |
| | | import com.zy.system.service.UserService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.web.bind.annotation.*; |
| | |
| | | private UserService userService; |
| | | @Autowired |
| | | private RoleService roleService; |
| | | @Autowired |
| | | private UserLoginService userLoginService; |
| | | @Autowired |
| | | private I18nMessageService i18nMessageService; |
| | | |
| | | @RequestMapping(value = "/user/{id}/auth") |
| | | @ManagerAuth |
| | |
| | | return R.error(); |
| | | } |
| | | if (null == user.getId()){ |
| | | normalizeNewUser(user); |
| | | userService.save(user); |
| | | } else { |
| | | userService.updateById(user); |
| | | } |
| | | return R.ok(); |
| | | } |
| | | return update(user); |
| | | } |
| | | |
| | | @RequestMapping(value = "/user/add/auth") |
| | | @ManagerAuth(memo = "系统用户添加") |
| | | public R add(User user) { |
| | | normalizeNewUser(user); |
| | | userService.save(user); |
| | | return R.ok(); |
| | | } |
| | |
| | | return R.error(); |
| | | } |
| | | User entity = userService.getById(user.getId()); |
| | | if (Cools.isEmpty(entity)) { |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | | UpdateWrapper<User> wrapper = new UpdateWrapper<>(); |
| | | wrapper.eq("id", entity.getId()); |
| | | boolean needUpdate = false; |
| | | if (user.getPassword()!=null) { |
| | | entity.setPassword(user.getPassword()); |
| | | wrapper.set("password", user.getPassword()); |
| | | needUpdate = true; |
| | | } |
| | | if (user.getUsername()!=null) { |
| | | entity.setUsername(user.getUsername()); |
| | | wrapper.set("username", user.getUsername()); |
| | | needUpdate = true; |
| | | } |
| | | if (user.getMobile()!=null) { |
| | | entity.setMobile(user.getMobile()); |
| | | wrapper.set("mobile", user.getMobile()); |
| | | needUpdate = true; |
| | | } |
| | | if (user.getRoleId() !=null) { |
| | | entity.setRoleId(user.getRoleId()); |
| | | wrapper.set("role_id", user.getRoleId()); |
| | | needUpdate = true; |
| | | } |
| | | userService.updateById(entity); |
| | | if (user.getMfaAllow() != null) { |
| | | int mfaAllow = normalizeMfaAllow(user.getMfaAllow()); |
| | | wrapper.set("mfa_allow", mfaAllow); |
| | | if (mfaAllow != 1) { |
| | | wrapper.set("mfa_enabled", 0); |
| | | wrapper.set("mfa_secret", null); |
| | | wrapper.set("mfa_bound_time", null); |
| | | } |
| | | needUpdate = true; |
| | | } |
| | | if (!needUpdate) { |
| | | return R.ok(); |
| | | } |
| | | userService.update(wrapper); |
| | | return R.ok(); |
| | | } |
| | | |
| | | @RequestMapping(value = "/user/password/update/auth") |
| | | @ManagerAuth(memo = "系统用户修改密码") |
| | | public R updatePassword(String oldPassword, String password) { |
| | | if (Cools.isEmpty(oldPassword, password)) { |
| | | return R.error(); |
| | | } |
| | | User user = userService.getById(getUserId()); |
| | | if (Cools.isEmpty(user)) { |
| | | return new R(10001, i18nMessageService.getMessage("response.user.notFound")); |
| | | } |
| | | if (!Cools.eq(user.getPassword(), oldPassword)) { |
| | | return new R(10008, i18nMessageService.getMessage("response.user.oldPasswordMismatch")); |
| | | } |
| | | userService.update(new UpdateWrapper<User>() |
| | | .eq("id", user.getId()) |
| | | .set("password", password)); |
| | | userLoginService.remove(new QueryWrapper<UserLogin>().eq("user_id", user.getId()).eq("system_type", "WCS")); |
| | | return R.ok(); |
| | | } |
| | | |
| | |
| | | return R.ok(result); |
| | | } |
| | | |
| | | private void normalizeNewUser(User user) { |
| | | if (Cools.isEmpty(user)) { |
| | | return; |
| | | } |
| | | int mfaAllow = normalizeMfaAllow(user.getMfaAllow()); |
| | | user.setMfaAllow(mfaAllow); |
| | | if (mfaAllow != 1) { |
| | | user.setMfaEnabled(0); |
| | | user.setMfaSecret(null); |
| | | user.setMfaBoundTime(null); |
| | | } else if (user.getMfaEnabled() == null) { |
| | | user.setMfaEnabled(0); |
| | | } |
| | | } |
| | | |
| | | private int normalizeMfaAllow(Integer mfaAllow) { |
| | | return Integer.valueOf(1).equals(mfaAllow) ? 1 : 0; |
| | | } |
| | | |
| | | } |
| | |
| | | private String password; |
| | | |
| | | /** |
| | | * 是否允许使用 MFA |
| | | */ |
| | | @TableField("mfa_allow") |
| | | private Integer mfaAllow; |
| | | |
| | | /** |
| | | * 是否已启用 MFA |
| | | */ |
| | | @TableField("mfa_enabled") |
| | | private Integer mfaEnabled; |
| | | |
| | | /** |
| | | * MFA 密钥 |
| | | */ |
| | | @TableField(value = "mfa_secret", select = false) |
| | | private String mfaSecret; |
| | | |
| | | /** |
| | | * MFA 绑定时间 |
| | | */ |
| | | @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") |
| | | @TableField("mfa_bound_time") |
| | | private Date mfaBoundTime; |
| | | |
| | | /** |
| | | * 角色 |
| | | */ |
| | | @TableField("role_id") |
| | |
| | | this.password = password; |
| | | } |
| | | |
| | | public Integer getMfaAllow() { |
| | | return mfaAllow; |
| | | } |
| | | |
| | | public String getMfaAllow$() { |
| | | if (null == this.mfaAllow) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.mfaAllow) ? "是" : "否"; |
| | | } |
| | | |
| | | public void setMfaAllow(Integer mfaAllow) { |
| | | this.mfaAllow = mfaAllow; |
| | | } |
| | | |
| | | public Integer getMfaEnabled() { |
| | | return mfaEnabled; |
| | | } |
| | | |
| | | public String getMfaEnabled$() { |
| | | if (null == this.mfaEnabled) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.mfaEnabled) ? "是" : "否"; |
| | | } |
| | | |
| | | public void setMfaEnabled(Integer mfaEnabled) { |
| | | this.mfaEnabled = mfaEnabled; |
| | | } |
| | | |
| | | public String getMfaSecret() { |
| | | return mfaSecret; |
| | | } |
| | | |
| | | public void setMfaSecret(String mfaSecret) { |
| | | this.mfaSecret = mfaSecret; |
| | | } |
| | | |
| | | public Date getMfaBoundTime() { |
| | | return mfaBoundTime; |
| | | } |
| | | |
| | | public String getMfaBoundTime$() { |
| | | if (Cools.isEmpty(this.mfaBoundTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.mfaBoundTime); |
| | | } |
| | | |
| | | public void setMfaBoundTime(Date mfaBoundTime) { |
| | | this.mfaBoundTime = mfaBoundTime; |
| | | } |
| | | |
| | | public Long getRoleId() { |
| | | return roleId; |
| | | } |
| | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.zy.system.entity.User; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.apache.ibatis.annotations.Param; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface UserMapper extends BaseMapper<User> { |
| | | |
| | | User selectByMobileWithMfa(@Param("mobile") String mobile); |
| | | |
| | | User selectByIdWithMfa(@Param("id") Long id); |
| | | } |
| | |
| | | |
| | | public interface UserService extends IService<User> { |
| | | |
| | | User getByMobileWithMfa(String mobile); |
| | | |
| | | User getByIdWithMfa(Long id); |
| | | } |
| | |
| | | @Service("userService") |
| | | public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { |
| | | |
| | | @Override |
| | | public User getByMobileWithMfa(String mobile) { |
| | | return baseMapper.selectByMobileWithMfa(mobile); |
| | | } |
| | | |
| | | @Override |
| | | public User getByIdWithMfa(Long id) { |
| | | return baseMapper.selectByIdWithMfa(id); |
| | | } |
| | | } |
| | |
| | | response.user.notFound=Account does not exist |
| | | response.user.disabled=Account is disabled |
| | | response.user.passwordMismatch=Incorrect password |
| | | 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 |
| | | response.user.mfaCodeMismatch=Incorrect MFA verification code |
| | | response.user.mfaTicketExpired=The MFA login ticket has expired. Please sign in again |
| | | response.system.licenseExpired=License has expired |
| | | response.common.systemError=System error. Please try again later. |
| | | response.common.methodNotAllowed=Request method not supported |
| | |
| | | response.user.notFound=账号不存在 |
| | | response.user.disabled=账号已被禁用 |
| | | response.user.passwordMismatch=密码错误 |
| | | response.user.oldPasswordMismatch=当前密码错误 |
| | | response.user.mfaNotAllowed=当前账号未开通MFA使用权限 |
| | | response.user.mfaNotEnabled=当前账号未启用MFA |
| | | response.user.mfaCodeMismatch=MFA验证码错误 |
| | | response.user.mfaTicketExpired=MFA登录票据已失效,请重新输入账号密码 |
| | | response.system.licenseExpired=许可证已失效 |
| | | response.common.systemError=系统异常,请稍后重试 |
| | | response.common.methodNotAllowed=请求方式不支持 |
| | |
| | | <result column="username" property="username" /> |
| | | <result column="mobile" property="mobile" /> |
| | | <result column="password" property="password" /> |
| | | <result column="mfa_allow" property="mfaAllow" /> |
| | | <result column="mfa_enabled" property="mfaEnabled" /> |
| | | <result column="mfa_bound_time" property="mfaBoundTime" /> |
| | | <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"> |
| | | <result column="mfa_secret" property="mfaSecret" /> |
| | | </resultMap> |
| | | |
| | | <select id="selectByMobileWithMfa" resultMap="MfaResultMap"> |
| | | select |
| | | id, |
| | | host_id, |
| | | username, |
| | | mobile, |
| | | password, |
| | | mfa_allow, |
| | | mfa_enabled, |
| | | mfa_secret, |
| | | mfa_bound_time, |
| | | role_id, |
| | | create_time, |
| | | status |
| | | from sys_user |
| | | where mobile = #{mobile} |
| | | limit 1 |
| | | </select> |
| | | |
| | | <select id="selectByIdWithMfa" resultMap="MfaResultMap"> |
| | | select |
| | | id, |
| | | host_id, |
| | | username, |
| | | mobile, |
| | | password, |
| | | mfa_allow, |
| | | mfa_enabled, |
| | | mfa_secret, |
| | | mfa_bound_time, |
| | | role_id, |
| | | create_time, |
| | | status |
| | | from sys_user |
| | | where id = #{id} |
| | | limit 1 |
| | | </select> |
| | | </mapper> |
| New file |
| | |
| | | -- sys_user 增加 MFA 相关字段 |
| | | -- 用途:支持账号级多因子登录授权、绑定和校验 |
| | | -- 适用数据库:MySQL |
| | | |
| | | SET @current_db := DATABASE(); |
| | | |
| | | SET @mfa_allow_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @current_db |
| | | AND TABLE_NAME = 'sys_user' |
| | | AND COLUMN_NAME = 'mfa_allow' |
| | | ); |
| | | |
| | | SET @add_mfa_allow_sql := IF( |
| | | @mfa_allow_exists = 0, |
| | | 'ALTER TABLE sys_user ADD COLUMN mfa_allow INT NOT NULL DEFAULT 0 COMMENT ''是否允许使用MFA'' AFTER password', |
| | | 'SELECT ''column mfa_allow already exists'' ' |
| | | ); |
| | | PREPARE stmt_mfa_allow FROM @add_mfa_allow_sql; |
| | | EXECUTE stmt_mfa_allow; |
| | | DEALLOCATE PREPARE stmt_mfa_allow; |
| | | |
| | | SET @mfa_enabled_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @current_db |
| | | AND TABLE_NAME = 'sys_user' |
| | | AND COLUMN_NAME = 'mfa_enabled' |
| | | ); |
| | | |
| | | SET @add_mfa_enabled_sql := IF( |
| | | @mfa_enabled_exists = 0, |
| | | 'ALTER TABLE sys_user ADD COLUMN mfa_enabled INT NOT NULL DEFAULT 0 COMMENT ''是否已启用MFA'' AFTER mfa_allow', |
| | | 'SELECT ''column mfa_enabled already exists'' ' |
| | | ); |
| | | PREPARE stmt_mfa_enabled FROM @add_mfa_enabled_sql; |
| | | EXECUTE stmt_mfa_enabled; |
| | | DEALLOCATE PREPARE stmt_mfa_enabled; |
| | | |
| | | SET @mfa_secret_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @current_db |
| | | AND TABLE_NAME = 'sys_user' |
| | | AND COLUMN_NAME = 'mfa_secret' |
| | | ); |
| | | |
| | | SET @add_mfa_secret_sql := IF( |
| | | @mfa_secret_exists = 0, |
| | | 'ALTER TABLE sys_user ADD COLUMN mfa_secret VARCHAR(128) NULL COMMENT ''MFA密钥'' AFTER mfa_enabled', |
| | | 'SELECT ''column mfa_secret already exists'' ' |
| | | ); |
| | | PREPARE stmt_mfa_secret FROM @add_mfa_secret_sql; |
| | | EXECUTE stmt_mfa_secret; |
| | | DEALLOCATE PREPARE stmt_mfa_secret; |
| | | |
| | | SET @mfa_bound_time_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @current_db |
| | | AND TABLE_NAME = 'sys_user' |
| | | AND COLUMN_NAME = 'mfa_bound_time' |
| | | ); |
| | | |
| | | SET @add_mfa_bound_time_sql := IF( |
| | | @mfa_bound_time_exists = 0, |
| | | 'ALTER TABLE sys_user ADD COLUMN mfa_bound_time DATETIME NULL COMMENT ''MFA绑定时间'' AFTER mfa_secret', |
| | | 'SELECT ''column mfa_bound_time already exists'' ' |
| | | ); |
| | | PREPARE stmt_mfa_bound_time FROM @add_mfa_bound_time_sql; |
| | | EXECUTE stmt_mfa_bound_time; |
| | | DEALLOCATE PREPARE stmt_mfa_bound_time; |
| | | |
| | | SHOW COLUMNS FROM sys_user LIKE 'mfa_allow'; |
| | | SHOW COLUMNS FROM sys_user LIKE 'mfa_enabled'; |
| | | SHOW COLUMNS FROM sys_user LIKE 'mfa_secret'; |
| | | SHOW COLUMNS FROM sys_user LIKE 'mfa_bound_time'; |
| | |
| | | saving: false, |
| | | passwordDialogVisible: false, |
| | | passwordSaving: false, |
| | | mfaDialogVisible: false, |
| | | mfaDialogMode: "enable", |
| | | mfaSetupLoading: false, |
| | | mfaSubmitting: false, |
| | | form: { |
| | | id: "", |
| | | roleName: "", |
| | | username: "", |
| | | mobile: "", |
| | | password: "", |
| | | createTime$: "" |
| | | createTime$: "", |
| | | mfaAllow: 0, |
| | | mfaAllow$: "否", |
| | | mfaEnabled: 0, |
| | | mfaEnabled$: "否", |
| | | mfaBoundTime$: "", |
| | | mfaMaskedSecret: "" |
| | | }, |
| | | passwordForm: { |
| | | oldPassword: "", |
| | | password: "", |
| | | rePassword: "" |
| | | }, |
| | | mfaForm: { |
| | | currentPassword: "", |
| | | code: "" |
| | | }, |
| | | mfaSetup: { |
| | | secret: "", |
| | | qrCode: "", |
| | | otpAuth: "" |
| | | }, |
| | | rules: { |
| | | username: [ |
| | |
| | | }, |
| | | passwordRules: { |
| | | oldPassword: [ |
| | | { required: true, message: "请输入当前密码", trigger: "blur" }, |
| | | { |
| | | validator: function (rule, value, callback) { |
| | | if (!value) { |
| | | callback(new Error("请输入当前密码")); |
| | | return; |
| | | } |
| | | if (!this.form.password) { |
| | | callback(new Error("未读取到当前用户密码")); |
| | | return; |
| | | } |
| | | if (hex_md5(value) !== this.form.password) { |
| | | callback(new Error("密码不匹配")); |
| | | return; |
| | | } |
| | | callback(); |
| | | }.bind(this), |
| | | trigger: "blur" |
| | | } |
| | | { required: true, message: "请输入当前密码", trigger: "blur" } |
| | | ], |
| | | password: [ |
| | | { required: true, message: "请输入新密码", trigger: "blur" }, |
| | |
| | | callback(new Error("不能少于4个字符")); |
| | | return; |
| | | } |
| | | if (this.form.password && hex_md5(value) === this.form.password) { |
| | | if (value === this.passwordForm.oldPassword) { |
| | | callback(new Error("与旧密码不能相同")); |
| | | return; |
| | | } |
| | |
| | | } |
| | | callback(); |
| | | }.bind(this), |
| | | trigger: "blur" |
| | | } |
| | | ] |
| | | }, |
| | | mfaRules: { |
| | | currentPassword: [ |
| | | { required: true, message: "请输入当前密码", trigger: "blur" } |
| | | ], |
| | | code: [ |
| | | { required: true, message: "请输入6位验证码", trigger: "blur" }, |
| | | { |
| | | validator: function (rule, value, callback) { |
| | | if (!/^\d{6}$/.test(String(value || "").trim())) { |
| | | callback(new Error("请输入6位数字验证码")); |
| | | return; |
| | | } |
| | | callback(); |
| | | }, |
| | | trigger: "blur" |
| | | } |
| | | ] |
| | |
| | | var vm = this; |
| | | vm.passwordSaving = true; |
| | | $.ajax({ |
| | | url: baseUrl + "/user/update/auth", |
| | | url: baseUrl + "/user/password/update/auth", |
| | | headers: { token: localStorage.getItem("token") }, |
| | | data: { |
| | | id: vm.form.id, |
| | | oldPassword: hex_md5(vm.passwordForm.oldPassword), |
| | | password: hex_md5(vm.passwordForm.password) |
| | | }, |
| | | method: "POST", |
| | |
| | | vm.$message.error(res.msg || "密码修改失败"); |
| | | return; |
| | | } |
| | | vm.form.password = hex_md5(vm.passwordForm.password); |
| | | vm.passwordDialogVisible = false; |
| | | vm.$alert("密码修改成功,请重新登录", "提示", { |
| | | confirmButtonText: "确定", |
| | |
| | | } |
| | | }); |
| | | }, |
| | | openMfaEnableDialog: function () { |
| | | if (Number(this.form.mfaAllow) !== 1) { |
| | | this.$message.warning("当前账号未开通MFA使用权限"); |
| | | return; |
| | | } |
| | | this.mfaDialogMode = "enable"; |
| | | this.resetMfaDialogState(); |
| | | this.mfaDialogVisible = true; |
| | | this.loadMfaSetup(); |
| | | }, |
| | | openMfaDisableDialog: function () { |
| | | this.mfaDialogMode = "disable"; |
| | | this.resetMfaDialogState(); |
| | | this.mfaDialogVisible = true; |
| | | }, |
| | | closeMfaDialog: function () { |
| | | this.mfaDialogVisible = false; |
| | | this.resetMfaDialogState(); |
| | | }, |
| | | resetMfaDialogState: function () { |
| | | this.mfaSubmitting = false; |
| | | this.mfaSetupLoading = false; |
| | | this.mfaForm = { |
| | | currentPassword: "", |
| | | code: "" |
| | | }; |
| | | this.mfaSetup = { |
| | | secret: "", |
| | | qrCode: "", |
| | | otpAuth: "" |
| | | }; |
| | | if (this.$refs.mfaForm) { |
| | | this.$refs.mfaForm.clearValidate(); |
| | | } |
| | | }, |
| | | loadMfaSetup: function () { |
| | | var vm = this; |
| | | vm.mfaSetupLoading = true; |
| | | $.ajax({ |
| | | url: baseUrl + "/user/mfa/setup/auth", |
| | | headers: { token: localStorage.getItem("token") }, |
| | | method: "POST", |
| | | success: function (res) { |
| | | if (handleForbidden(res)) { |
| | | return; |
| | | } |
| | | if (Number(res.code) !== 200) { |
| | | vm.$message.error(res.msg || "MFA配置加载失败"); |
| | | return; |
| | | } |
| | | vm.mfaSetup = Object.assign({}, vm.mfaSetup, res.data || {}); |
| | | }, |
| | | error: function () { |
| | | vm.$message.error("MFA配置加载失败"); |
| | | }, |
| | | complete: function () { |
| | | vm.mfaSetupLoading = false; |
| | | } |
| | | }); |
| | | }, |
| | | handleMfaSubmit: function () { |
| | | var vm = this; |
| | | if (!vm.$refs.mfaForm) { |
| | | return; |
| | | } |
| | | vm.$refs.mfaForm.validate(function (valid) { |
| | | if (!valid) { |
| | | return false; |
| | | } |
| | | if (vm.mfaDialogMode === "enable" && !vm.mfaSetup.secret) { |
| | | vm.$message.error("MFA密钥尚未生成,请稍后重试"); |
| | | return false; |
| | | } |
| | | vm.submitMfaAction(); |
| | | return true; |
| | | }); |
| | | }, |
| | | submitMfaAction: function () { |
| | | var vm = this; |
| | | vm.mfaSubmitting = true; |
| | | $.ajax({ |
| | | url: baseUrl + (vm.mfaDialogMode === "enable" ? "/user/mfa/enable/auth" : "/user/mfa/disable/auth"), |
| | | headers: { token: localStorage.getItem("token") }, |
| | | data: { |
| | | currentPassword: hex_md5(vm.mfaForm.currentPassword), |
| | | code: vm.mfaForm.code, |
| | | secret: vm.mfaSetup.secret |
| | | }, |
| | | method: "POST", |
| | | success: function (res) { |
| | | if (handleForbidden(res)) { |
| | | return; |
| | | } |
| | | if (Number(res.code) !== 200) { |
| | | vm.$message.error(res.msg || "MFA操作失败"); |
| | | return; |
| | | } |
| | | vm.$message.success(vm.mfaDialogMode === "enable" ? "MFA已启用" : "MFA已停用"); |
| | | vm.closeMfaDialog(); |
| | | vm.loadDetail(); |
| | | }, |
| | | error: function () { |
| | | vm.$message.error("MFA操作失败"); |
| | | }, |
| | | complete: function () { |
| | | vm.mfaSubmitting = false; |
| | | } |
| | | }); |
| | | }, |
| | | copySecret: function () { |
| | | var vm = this; |
| | | var text = vm.mfaSetup.secret || ""; |
| | | if (!text) { |
| | | return; |
| | | } |
| | | if (navigator.clipboard && navigator.clipboard.writeText) { |
| | | navigator.clipboard.writeText(text).then(function () { |
| | | vm.$message.success("密钥已复制"); |
| | | }).catch(function () { |
| | | vm.fallbackCopy(text); |
| | | }); |
| | | return; |
| | | } |
| | | vm.fallbackCopy(text); |
| | | }, |
| | | fallbackCopy: function (text) { |
| | | try { |
| | | var textarea = document.createElement("textarea"); |
| | | textarea.value = text; |
| | | textarea.style.position = "fixed"; |
| | | textarea.style.opacity = "0"; |
| | | document.body.appendChild(textarea); |
| | | textarea.select(); |
| | | document.execCommand("copy"); |
| | | document.body.removeChild(textarea); |
| | | this.$message.success("密钥已复制"); |
| | | } catch (err) { |
| | | this.$message.error("复制失败"); |
| | | } |
| | | }, |
| | | handleSave: function () { |
| | | var vm = this; |
| | | vm.$refs.profileForm.validate(function (valid) { |
| | |
| | | localeOptions: [], |
| | | currentLocale: "zh-CN", |
| | | loginLoading: false, |
| | | mfaLoading: false, |
| | | toolsDialogVisible: false, |
| | | textDialogVisible: false, |
| | | uploadDialogVisible: false, |
| | | mfaDialogVisible: false, |
| | | licenseBase64: "", |
| | | titleClickCount: 0, |
| | | titleClickTimer: null, |
| | | loginForm: { |
| | | mobile: "", |
| | | password: "" |
| | | }, |
| | | mfaForm: { |
| | | code: "" |
| | | }, |
| | | mfaPending: { |
| | | ticket: "", |
| | | username: "" |
| | | }, |
| | | textDialog: { |
| | | title: "", |
| | |
| | | ], |
| | | password: [ |
| | | { required: true, message: "请输入密码", trigger: "blur" } |
| | | ] |
| | | }, |
| | | mfaRules: { |
| | | code: [ |
| | | { required: true, message: "请输入6位验证码", trigger: "blur" }, |
| | | { |
| | | validator: function (rule, value, callback) { |
| | | if (!/^\d{6}$/.test(String(value || "").trim())) { |
| | | callback(new Error("请输入6位数字验证码")); |
| | | return; |
| | | } |
| | | callback(); |
| | | }, |
| | | trigger: "blur" |
| | | } |
| | | ] |
| | | } |
| | | }; |
| | |
| | | }, |
| | | method: "POST", |
| | | success: function (res) { |
| | | var payload = res && res.data ? res.data : {}; |
| | | if (Number(res.code) === 200) { |
| | | localStorage.setItem("token", res.data.token); |
| | | localStorage.setItem("username", res.data.username); |
| | | window.location.href = "index.html"; |
| | | if (payload.mfaRequired) { |
| | | vm.openMfaDialog(payload); |
| | | return; |
| | | } |
| | | vm.finishLogin(payload); |
| | | return; |
| | | } |
| | | vm.$message.error(res.msg || "登录失败"); |
| | |
| | | } |
| | | }); |
| | | }, |
| | | openMfaDialog: function (payload) { |
| | | this.mfaPending = { |
| | | ticket: payload.mfaTicket || "", |
| | | username: payload.username || this.loginForm.mobile || "" |
| | | }; |
| | | this.mfaForm.code = ""; |
| | | this.mfaDialogVisible = true; |
| | | if (this.$refs.mfaForm) { |
| | | this.$nextTick(function () { |
| | | this.$refs.mfaForm.clearValidate(); |
| | | }); |
| | | } |
| | | }, |
| | | closeMfaDialog: function () { |
| | | this.mfaDialogVisible = false; |
| | | this.mfaLoading = false; |
| | | this.mfaPending = { |
| | | ticket: "", |
| | | username: "" |
| | | }; |
| | | this.mfaForm.code = ""; |
| | | if (this.$refs.mfaForm) { |
| | | this.$refs.mfaForm.clearValidate(); |
| | | } |
| | | }, |
| | | handleMfaLogin: function () { |
| | | var vm = this; |
| | | if (!vm.$refs.mfaForm) { |
| | | return; |
| | | } |
| | | vm.$refs.mfaForm.validate(function (valid) { |
| | | if (!valid) { |
| | | return false; |
| | | } |
| | | vm.submitMfaLogin(); |
| | | return true; |
| | | }); |
| | | }, |
| | | submitMfaLogin: function () { |
| | | var vm = this; |
| | | if (!vm.mfaPending.ticket) { |
| | | vm.$message.error("登录票据已失效,请重新登录"); |
| | | vm.closeMfaDialog(); |
| | | return; |
| | | } |
| | | vm.mfaLoading = true; |
| | | ajaxJson({ |
| | | url: baseUrl + "/login/mfa.action", |
| | | data: { |
| | | ticket: vm.mfaPending.ticket, |
| | | code: vm.mfaForm.code |
| | | }, |
| | | method: "POST", |
| | | success: function (res) { |
| | | if (Number(res.code) === 200) { |
| | | vm.finishLogin(res.data || {}); |
| | | return; |
| | | } |
| | | vm.$message.error(res.msg || "验证失败"); |
| | | }, |
| | | error: function () { |
| | | vm.$message.error("验证失败"); |
| | | }, |
| | | complete: function () { |
| | | vm.mfaLoading = false; |
| | | } |
| | | }); |
| | | }, |
| | | finishLogin: function (payload) { |
| | | localStorage.setItem("token", payload.token || ""); |
| | | localStorage.setItem("username", payload.username || this.loginForm.mobile || ""); |
| | | this.closeMfaDialog(); |
| | | window.location.href = "index.html"; |
| | | }, |
| | | openTextDialog: function (title, label, text, tip) { |
| | | var pretty = ""; |
| | | try { |
| | |
| | | } |
| | | |
| | | ]); |
| | | fieldMeta.push({ |
| | | field: 'mfaAllow', |
| | | columnName: 'mfa_allow', |
| | | label: 'MFA授权', |
| | | tableProp: 'mfaAllow', |
| | | exportField: 'mfaAllow$', |
| | | kind: 'checkbox', |
| | | valueType: 'number', |
| | | required: false, |
| | | primaryKey: false, |
| | | sortable: false, |
| | | textarea: false, |
| | | minWidth: 110, |
| | | enumOptions: [], |
| | | foreignQuery: '', |
| | | checkboxActiveRaw: '1', |
| | | checkboxInactiveRaw: '0', |
| | | searchable: false |
| | | }); |
| | | fieldMeta.push({ |
| | | field: 'mfaEnabled', |
| | | columnName: 'mfa_enabled', |
| | | label: 'MFA启用', |
| | | tableProp: 'mfaEnabled', |
| | | exportField: 'mfaEnabled$', |
| | | kind: 'checkbox', |
| | | valueType: 'number', |
| | | required: false, |
| | | primaryKey: false, |
| | | sortable: false, |
| | | textarea: false, |
| | | minWidth: 110, |
| | | enumOptions: [], |
| | | foreignQuery: '', |
| | | checkboxActiveRaw: '1', |
| | | checkboxInactiveRaw: '0', |
| | | searchable: false, |
| | | editable: false |
| | | }); |
| | | fieldMeta.push({ |
| | | field: 'mfaBoundTime', |
| | | columnName: 'mfa_bound_time', |
| | | label: 'MFA绑定时间', |
| | | tableProp: 'mfaBoundTime$', |
| | | exportField: 'mfaBoundTime$', |
| | | kind: 'text', |
| | | valueType: 'string', |
| | | required: false, |
| | | primaryKey: false, |
| | | sortable: false, |
| | | textarea: false, |
| | | minWidth: 168, |
| | | enumOptions: [], |
| | | foreignQuery: '', |
| | | checkboxActiveRaw: 'Y', |
| | | checkboxInactiveRaw: 'N', |
| | | searchable: false, |
| | | editable: false |
| | | }); |
| | | |
| | | function formatFieldLabel(field) { |
| | | var raw = field && field.label ? String(field.label).trim() : ''; |
| | |
| | | } |
| | | |
| | | function isSearchableField(field) { |
| | | return !!field && field.kind !== 'image' && !field.textarea; |
| | | return !!field && field.kind !== 'image' && !field.textarea && field.searchable !== false; |
| | | } |
| | | |
| | | function isSortableField(field) { |
| | |
| | | } |
| | | |
| | | function createDefaultVisibleColumnKeys() { |
| | | return fieldMeta.map(function (field) { |
| | | return fieldMeta.filter(function (field) { |
| | | return field.visible !== false; |
| | | }).map(function (field) { |
| | | return field.field; |
| | | }); |
| | | } |
| | |
| | | }, |
| | | editableFields: function () { |
| | | return this.fieldMeta.filter(function (field) { |
| | | return !field.primaryKey; |
| | | return !field.primaryKey && field.editable !== false; |
| | | }); |
| | | }, |
| | | exportColumns: function () { |
| | |
| | | flex: 0 0 118px; |
| | | } |
| | | |
| | | .mfa-panel { |
| | | border: 1px solid rgba(222, 230, 239, 0.92); |
| | | border-radius: 14px; |
| | | background: #f8fbff; |
| | | padding: 14px 16px; |
| | | } |
| | | |
| | | .mfa-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .mfa-title { |
| | | font-size: 14px; |
| | | font-weight: 700; |
| | | color: #2f4358; |
| | | } |
| | | |
| | | .mfa-actions { |
| | | display: flex; |
| | | gap: 10px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .mfa-meta { |
| | | display: grid; |
| | | grid-template-columns: repeat(3, minmax(0, 1fr)); |
| | | gap: 10px; |
| | | } |
| | | |
| | | .mfa-meta-item { |
| | | padding: 10px 12px; |
| | | border-radius: 12px; |
| | | background: rgba(255, 255, 255, 0.95); |
| | | border: 1px solid rgba(226, 233, 242, 0.96); |
| | | } |
| | | |
| | | .mfa-meta-label { |
| | | font-size: 12px; |
| | | color: #7b8b9b; |
| | | margin-bottom: 6px; |
| | | } |
| | | |
| | | .mfa-meta-value { |
| | | font-size: 13px; |
| | | color: #2f4358; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .mfa-tip { |
| | | margin-top: 10px; |
| | | font-size: 12px; |
| | | line-height: 1.7; |
| | | color: #7b8b9b; |
| | | } |
| | | |
| | | .footer-bar { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .password-dialog .el-dialog__header { |
| | | .password-dialog .el-dialog__header, |
| | | .mfa-dialog .el-dialog__header { |
| | | padding: 18px 20px 12px; |
| | | border-bottom: 1px solid rgba(222, 230, 239, 0.92); |
| | | background: #f8fbff; |
| | | } |
| | | |
| | | .password-dialog .el-dialog__title { |
| | | .password-dialog .el-dialog__title, |
| | | .mfa-dialog .el-dialog__title { |
| | | font-weight: 700; |
| | | color: var(--text-main); |
| | | } |
| | | |
| | | .password-dialog .el-dialog__body { |
| | | .password-dialog .el-dialog__body, |
| | | .mfa-dialog .el-dialog__body { |
| | | padding: 18px 20px 12px; |
| | | } |
| | | |
| | |
| | | padding-top: 4px; |
| | | } |
| | | |
| | | .mfa-setup { |
| | | margin-bottom: 16px; |
| | | padding: 14px; |
| | | border-radius: 14px; |
| | | background: rgba(248, 251, 255, 0.92); |
| | | border: 1px solid rgba(226, 233, 242, 0.96); |
| | | } |
| | | |
| | | .mfa-setup-tip { |
| | | font-size: 12px; |
| | | line-height: 1.7; |
| | | color: #738396; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .mfa-qr-wrap { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | min-height: 220px; |
| | | margin-bottom: 14px; |
| | | background: #fff; |
| | | border-radius: 14px; |
| | | border: 1px dashed rgba(210, 220, 233, 0.96); |
| | | } |
| | | |
| | | .mfa-qr-wrap img { |
| | | width: 220px; |
| | | height: 220px; |
| | | object-fit: contain; |
| | | } |
| | | |
| | | .mfa-secret-row { |
| | | display: flex; |
| | | gap: 10px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .mfa-secret-row .el-input { |
| | | flex: 1; |
| | | } |
| | | |
| | | .mfa-footer { |
| | | display: flex; |
| | | justify-content: center; |
| | | gap: 12px; |
| | | padding-top: 6px; |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .page-shell { |
| | | padding: 10px; |
| | | } |
| | | |
| | | .password-row { |
| | | .password-row, |
| | | .mfa-secret-row { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | |
| | | .form-shell { |
| | | max-width: none; |
| | | } |
| | | |
| | | .mfa-head { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | |
| | | .mfa-meta { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <div class="card-body"> |
| | | <div class="form-shell"> |
| | | <input id="id" type="hidden" v-model="form.id"> |
| | | <input id="password" type="hidden" v-model="form.password"> |
| | | <el-form |
| | | ref="profileForm" |
| | | class="profile-form" |
| | |
| | | <div class="password-row"> |
| | | <el-input class="password-mask" value="已设置密码" disabled></el-input> |
| | | <el-button type="primary" plain icon="el-icon-lock" @click="openPasswordDialog">修改密码</el-button> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :xs="24"> |
| | | <el-form-item label="MFA"> |
| | | <div class="mfa-panel"> |
| | | <div class="mfa-head"> |
| | | <div class="mfa-title">多因子登录验证</div> |
| | | <div class="mfa-actions"> |
| | | <el-button |
| | | v-if="Number(form.mfaAllow) === 1 && Number(form.mfaEnabled) !== 1" |
| | | type="primary" |
| | | plain |
| | | icon="el-icon-key" |
| | | @click="openMfaEnableDialog">启用 MFA</el-button> |
| | | <el-button |
| | | v-if="Number(form.mfaEnabled) === 1" |
| | | type="danger" |
| | | plain |
| | | icon="el-icon-switch-button" |
| | | @click="openMfaDisableDialog">停用 MFA</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.mfaAllow$ || '--' }}</div> |
| | | </div> |
| | | <div class="mfa-meta-item"> |
| | | <div class="mfa-meta-label">启用状态</div> |
| | | <div class="mfa-meta-value">{{ form.mfaEnabled$ || '--' }}</div> |
| | | </div> |
| | | <div class="mfa-meta-item"> |
| | | <div class="mfa-meta-label">绑定时间</div> |
| | | <div class="mfa-meta-value">{{ form.mfaBoundTime$ || '--' }}</div> |
| | | </div> |
| | | </div> |
| | | <div class="mfa-tip"> |
| | | <span v-if="Number(form.mfaAllow) !== 1">当前账号未开通 MFA 使用权限,请联系管理员授权。</span> |
| | | <span v-else-if="Number(form.mfaEnabled) === 1">已绑定密钥:{{ form.mfaMaskedSecret || '--' }}。登录时需要再输入一次动态验证码。</span> |
| | | <span v-else>当前账号已允许使用 MFA,启用后登录会增加一次 6 位动态验证码校验。</span> |
| | | </div> |
| | | </div> |
| | | </el-form-item> |
| | | </el-col> |
| | |
| | | </div> |
| | | </el-form> |
| | | </el-dialog> |
| | | |
| | | <el-dialog |
| | | class="mfa-dialog" |
| | | :title="mfaDialogMode === 'enable' ? '启用 MFA' : '停用 MFA'" |
| | | :visible.sync="mfaDialogVisible" |
| | | width="520px" |
| | | :close-on-click-modal="false" |
| | | @close="closeMfaDialog" |
| | | append-to-body> |
| | | <div v-if="mfaDialogMode === 'enable'" class="mfa-setup"> |
| | | <div class="mfa-setup-tip">请先使用 Google Authenticator、Microsoft Authenticator 等身份验证器扫描二维码,再输入当前密码和 6 位动态码完成绑定。</div> |
| | | <div class="mfa-qr-wrap" v-loading="mfaSetupLoading"> |
| | | <img v-if="mfaSetup.qrCode" :src="mfaSetup.qrCode" alt="MFA QR Code"> |
| | | <span v-else>二维码生成中...</span> |
| | | </div> |
| | | <div class="mfa-secret-row"> |
| | | <el-input :value="mfaSetup.secret" readonly placeholder="MFA密钥"></el-input> |
| | | <el-button plain @click="copySecret">复制密钥</el-button> |
| | | </div> |
| | | </div> |
| | | <div v-else class="mfa-setup"> |
| | | <div class="mfa-setup-tip">停用 MFA 前,请输入当前密码和身份验证器中的 6 位动态码进行确认。</div> |
| | | </div> |
| | | <el-form |
| | | ref="mfaForm" |
| | | class="password-form" |
| | | :model="mfaForm" |
| | | :rules="mfaRules" |
| | | label-width="112px" |
| | | size="small" |
| | | @submit.native.prevent> |
| | | <el-form-item label="当前密码" prop="currentPassword"> |
| | | <el-input v-model="mfaForm.currentPassword" type="password" show-password autocomplete="off"></el-input> |
| | | </el-form-item> |
| | | <el-form-item label="动态验证码" prop="code"> |
| | | <el-input v-model.trim="mfaForm.code" maxlength="6" autocomplete="off" placeholder="请输入6位验证码"></el-input> |
| | | </el-form-item> |
| | | <div class="mfa-footer"> |
| | | <el-button @click="closeMfaDialog">关闭</el-button> |
| | | <el-button type="primary" :loading="mfaSubmitting" @click="handleMfaSubmit">保存</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/common.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=20260310_detail_vue3"></script> |
| | | <script type="text/javascript" src="../static/js/detail/detail.js?v=20260311_detail_mfa"></script> |
| | | </html> |
| | |
| | | |
| | | .tools-dialog .el-dialog, |
| | | .text-dialog .el-dialog, |
| | | .upload-dialog .el-dialog { |
| | | .upload-dialog .el-dialog, |
| | | .mfa-dialog .el-dialog { |
| | | border-radius: 20px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .tools-dialog .el-dialog__header, |
| | | .text-dialog .el-dialog__header, |
| | | .upload-dialog .el-dialog__header { |
| | | .upload-dialog .el-dialog__header, |
| | | .mfa-dialog .el-dialog__header { |
| | | padding: 18px 20px 12px; |
| | | border-bottom: 1px solid rgba(222, 230, 239, 0.92); |
| | | background: #f8fbff; |
| | |
| | | |
| | | .tools-dialog .el-dialog__title, |
| | | .text-dialog .el-dialog__title, |
| | | .upload-dialog .el-dialog__title { |
| | | .upload-dialog .el-dialog__title, |
| | | .mfa-dialog .el-dialog__title { |
| | | font-weight: 700; |
| | | color: #243447; |
| | | } |
| | | |
| | | .tools-dialog .el-dialog__body, |
| | | .text-dialog .el-dialog__body, |
| | | .upload-dialog .el-dialog__body { |
| | | .upload-dialog .el-dialog__body, |
| | | .mfa-dialog .el-dialog__body { |
| | | padding: 18px 20px 20px; |
| | | } |
| | | |
| | |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .mfa-tip { |
| | | color: #6f7f92; |
| | | font-size: 13px; |
| | | line-height: 1.7; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .mfa-account { |
| | | margin-bottom: 14px; |
| | | padding: 12px 14px; |
| | | border-radius: 14px; |
| | | background: rgba(244, 248, 253, 0.92); |
| | | color: #42576d; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .mfa-account strong { |
| | | color: #1e3956; |
| | | } |
| | | |
| | | .mfa-footer { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | gap: 10px; |
| | | margin-top: 14px; |
| | | } |
| | | |
| | | @media (max-width: 640px) { |
| | |
| | | </div> |
| | | </el-dialog> |
| | | |
| | | <el-dialog |
| | | class="mfa-dialog" |
| | | title="MFA二次验证" |
| | | :visible.sync="mfaDialogVisible" |
| | | width="420px" |
| | | :close-on-click-modal="false" |
| | | @close="closeMfaDialog" |
| | | append-to-body> |
| | | <div class="mfa-tip">账号密码已通过,请输入身份验证器中的 6 位动态验证码后继续登录。</div> |
| | | <div class="mfa-account">当前账号:<strong>{{ mfaPending.username || loginForm.mobile || '--' }}</strong></div> |
| | | <el-form ref="mfaForm" :model="mfaForm" :rules="mfaRules" label-width="82px" size="small" @submit.native.prevent> |
| | | <el-form-item label="验证码" prop="code"> |
| | | <el-input v-model.trim="mfaForm.code" maxlength="6" placeholder="请输入6位动态码" @keyup.enter.native="handleMfaLogin"></el-input> |
| | | </el-form-item> |
| | | </el-form> |
| | | <div class="mfa-footer"> |
| | | <el-button @click="closeMfaDialog">取消</el-button> |
| | | <el-button type="primary" :loading="mfaLoading" @click="handleMfaLogin">验证并登录</el-button> |
| | | </div> |
| | | </el-dialog> |
| | | |
| | | <el-dialog class="text-dialog" :title="textDialog.title" :visible.sync="textDialogVisible" width="720px" append-to-body> |
| | | <div class="dialog-text-label">{{ textDialog.label }}</div> |
| | | <div v-if="textDialog.tip" class="dialog-text-tip">{{ textDialog.tip }}</div> |
| | |
| | | <script type="text/javascript" src="../static/js/common.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=20260310_login_vue"></script> |
| | | <script type="text/javascript" src="../static/js/login/login.js?v=20260311_login_mfa"></script> |
| | | </html> |
| | |
| | | <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></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/user/user.js?v=20260310" charset="utf-8"></script> |
| | | <script type="text/javascript" src="../../static/js/user/user.js?v=20260311_mfa" charset="utf-8"></script> |
| | | </body> |
| | | </html> |