From be1cd9e5b30097ca427a9c2b7b054b28854e410a Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期三, 11 三月 2026 13:21:36 +0800
Subject: [PATCH] #
---
src/main/java/com/zy/system/controller/UserController.java | 87 +++
src/main/java/com/zy/system/mapper/UserMapper.java | 4
src/main/java/com/zy/system/service/UserService.java | 3
src/main/java/com/zy/common/utils/QrCode.java | 6
src/main/java/com/zy/system/entity/User.java | 78 +++
src/main/java/com/zy/common/auth/MfaLoginTicketManager.java | 73 ++
src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java | 52 ++
src/main/java/com/zy/system/service/impl/UserServiceImpl.java | 9
src/main/webapp/static/js/login/login.js | 107 ++++
src/main/resources/mapper/UserMapper.xml | 45 +
src/main/resources/i18n/zh-CN/messages.properties | 5
src/main/java/com/zy/common/web/AuthController.java | 203 +++++++
src/main/webapp/views/detail.html | 217 ++++++++
src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql | 78 +++
src/main/webapp/static/js/detail/detail.js | 207 +++++++-
src/main/webapp/views/user/user.html | 2
src/main/java/com/zy/common/utils/MfaTotpUtil.java | 148 +++++
src/main/resources/i18n/en-US/messages.properties | 5
src/main/webapp/static/js/user/user.js | 67 ++
src/main/webapp/views/login.html | 62 ++
20 files changed, 1,389 insertions(+), 69 deletions(-)
diff --git a/src/main/java/com/zy/common/auth/MfaLoginTicketManager.java b/src/main/java/com/zy/common/auth/MfaLoginTicketManager.java
new file mode 100644
index 0000000..85ae878
--- /dev/null
+++ b/src/main/java/com/zy/common/auth/MfaLoginTicketManager.java
@@ -0,0 +1,73 @@
+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;
+ }
+ }
+}
diff --git a/src/main/java/com/zy/common/utils/MfaTotpUtil.java b/src/main/java/com/zy/common/utils/MfaTotpUtil.java
new file mode 100644
index 0000000..01282a3
--- /dev/null
+++ b/src/main/java/com/zy/common/utils/MfaTotpUtil.java
@@ -0,0 +1,148 @@
+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");
+ }
+}
diff --git a/src/main/java/com/zy/common/utils/QrCode.java b/src/main/java/com/zy/common/utils/QrCode.java
index 174d713..b52fbd2 100644
--- a/src/main/java/com/zy/common/utils/QrCode.java
+++ b/src/main/java/com/zy/common/utils/QrCode.java
@@ -24,11 +24,15 @@
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);
diff --git a/src/main/java/com/zy/common/web/AuthController.java b/src/main/java/com/zy/common/web/AuthController.java
index cf7f800..89762e9 100644
--- a/src/main/java/com/zy/common/web/AuthController.java
+++ b/src/main/java/com/zy/common/web/AuthController.java
@@ -2,16 +2,18 @@
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.*;
@@ -25,6 +27,10 @@
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.*;
/**
@@ -53,6 +59,8 @@
private LicenseTimer licenseTimer;
@Autowired
private I18nMessageService i18nMessageService;
+ @Autowired
+ private MfaLoginTicketManager mfaLoginTicketManager;
@RequestMapping("/login.action")
@ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "鐧诲綍")
@@ -61,15 +69,16 @@
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"));
}
@@ -79,17 +88,41 @@
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);
- Map<String, Object> res = new HashMap<>();
- res.put("username", user.getUsername());
- res.put("token", token);
- return R.ok(res);
+ if (requiresMfa(user)) {
+ Map<String, Object> res = new HashMap<>();
+ res.put("username", user.getUsername());
+ 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")
@@ -122,7 +155,85 @@
@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")
@@ -246,6 +357,66 @@
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());
}
diff --git a/src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java b/src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java
new file mode 100644
index 0000000..4f3c0c2
--- /dev/null
+++ b/src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java
@@ -0,0 +1,52 @@
+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;
+ }
+}
diff --git a/src/main/java/com/zy/system/controller/UserController.java b/src/main/java/com/zy/system/controller/UserController.java
index 340d7fe..893868e 100644
--- a/src/main/java/com/zy/system/controller/UserController.java
+++ b/src/main/java/com/zy/system/controller/UserController.java
@@ -2,15 +2,19 @@
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.*;
@@ -24,6 +28,10 @@
private UserService userService;
@Autowired
private RoleService roleService;
+ @Autowired
+ private UserLoginService userLoginService;
+ @Autowired
+ private I18nMessageService i18nMessageService;
@RequestMapping(value = "/user/{id}/auth")
@ManagerAuth
@@ -82,16 +90,17 @@
return R.error();
}
if (null == user.getId()){
+ normalizeNewUser(user);
userService.save(user);
- } else {
- userService.updateById(user);
+ return R.ok();
}
- 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();
}
@@ -103,19 +112,62 @@
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();
}
@@ -155,4 +207,23 @@
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;
+ }
+
}
diff --git a/src/main/java/com/zy/system/entity/User.java b/src/main/java/com/zy/system/entity/User.java
index 519c0c6..78f470d 100644
--- a/src/main/java/com/zy/system/entity/User.java
+++ b/src/main/java/com/zy/system/entity/User.java
@@ -49,6 +49,31 @@
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")
@@ -115,6 +140,59 @@
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;
}
diff --git a/src/main/java/com/zy/system/mapper/UserMapper.java b/src/main/java/com/zy/system/mapper/UserMapper.java
index f6e625e..6ebb382 100644
--- a/src/main/java/com/zy/system/mapper/UserMapper.java
+++ b/src/main/java/com/zy/system/mapper/UserMapper.java
@@ -3,10 +3,14 @@
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);
}
diff --git a/src/main/java/com/zy/system/service/UserService.java b/src/main/java/com/zy/system/service/UserService.java
index 44b82cd..63fcbc4 100644
--- a/src/main/java/com/zy/system/service/UserService.java
+++ b/src/main/java/com/zy/system/service/UserService.java
@@ -5,4 +5,7 @@
public interface UserService extends IService<User> {
+ User getByMobileWithMfa(String mobile);
+
+ User getByIdWithMfa(Long id);
}
diff --git a/src/main/java/com/zy/system/service/impl/UserServiceImpl.java b/src/main/java/com/zy/system/service/impl/UserServiceImpl.java
index 8e8cc52..475733c 100644
--- a/src/main/java/com/zy/system/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/zy/system/service/impl/UserServiceImpl.java
@@ -9,4 +9,13 @@
@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);
+ }
}
diff --git a/src/main/resources/i18n/en-US/messages.properties b/src/main/resources/i18n/en-US/messages.properties
index f08806f..aeedcc1 100644
--- a/src/main/resources/i18n/en-US/messages.properties
+++ b/src/main/resources/i18n/en-US/messages.properties
@@ -59,6 +59,11 @@
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
diff --git a/src/main/resources/i18n/zh-CN/messages.properties b/src/main/resources/i18n/zh-CN/messages.properties
index 7fa647a..19a8d5d 100644
--- a/src/main/resources/i18n/zh-CN/messages.properties
+++ b/src/main/resources/i18n/zh-CN/messages.properties
@@ -59,6 +59,11 @@
response.user.notFound=璐﹀彿涓嶅瓨鍦�
response.user.disabled=璐﹀彿宸茶绂佺敤
response.user.passwordMismatch=瀵嗙爜閿欒
+response.user.oldPasswordMismatch=褰撳墠瀵嗙爜閿欒
+response.user.mfaNotAllowed=褰撳墠璐﹀彿鏈紑閫歁FA浣跨敤鏉冮檺
+response.user.mfaNotEnabled=褰撳墠璐﹀彿鏈惎鐢∕FA
+response.user.mfaCodeMismatch=MFA楠岃瘉鐮侀敊璇�
+response.user.mfaTicketExpired=MFA鐧诲綍绁ㄦ嵁宸插け鏁堬紝璇烽噸鏂拌緭鍏ヨ处鍙峰瘑鐮�
response.system.licenseExpired=璁稿彲璇佸凡澶辨晥
response.common.systemError=绯荤粺寮傚父锛岃绋嶅悗閲嶈瘯
response.common.methodNotAllowed=璇锋眰鏂瑰紡涓嶆敮鎸�
diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml
index 40c2a71..e1d5596 100644
--- a/src/main/resources/mapper/UserMapper.xml
+++ b/src/main/resources/mapper/UserMapper.xml
@@ -9,10 +9,53 @@
<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>
diff --git a/src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql b/src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql
new file mode 100644
index 0000000..6e7eded
--- /dev/null
+++ b/src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql
@@ -0,0 +1,78 @@
+-- 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 ''鏄惁宸插惎鐢∕FA'' 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';
diff --git a/src/main/webapp/static/js/detail/detail.js b/src/main/webapp/static/js/detail/detail.js
index 2c4c7d5..0e041d7 100644
--- a/src/main/webapp/static/js/detail/detail.js
+++ b/src/main/webapp/static/js/detail/detail.js
@@ -15,18 +15,36 @@
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: [
@@ -38,25 +56,7 @@
},
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" },
@@ -70,7 +70,7 @@
callback(new Error("涓嶈兘灏戜簬4涓瓧绗�"));
return;
}
- if (this.form.password && hex_md5(value) === this.form.password) {
+ if (value === this.passwordForm.oldPassword) {
callback(new Error("涓庢棫瀵嗙爜涓嶈兘鐩稿悓"));
return;
}
@@ -89,6 +89,24 @@
}
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"
}
]
@@ -155,10 +173,10 @@
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",
@@ -170,7 +188,6 @@
vm.$message.error(res.msg || "瀵嗙爜淇敼澶辫触");
return;
}
- vm.form.password = hex_md5(vm.passwordForm.password);
vm.passwordDialogVisible = false;
vm.$alert("瀵嗙爜淇敼鎴愬姛锛岃閲嶆柊鐧诲綍", "鎻愮ず", {
confirmButtonText: "纭畾",
@@ -188,6 +205,146 @@
}
});
},
+ openMfaEnableDialog: function () {
+ if (Number(this.form.mfaAllow) !== 1) {
+ this.$message.warning("褰撳墠璐﹀彿鏈紑閫歁FA浣跨敤鏉冮檺");
+ 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) {
diff --git a/src/main/webapp/static/js/login/login.js b/src/main/webapp/static/js/login/login.js
index b12ff13..6365e4c 100644
--- a/src/main/webapp/static/js/login/login.js
+++ b/src/main/webapp/static/js/login/login.js
@@ -11,15 +11,24 @@
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: "",
@@ -33,6 +42,21 @@
],
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"
+ }
]
}
};
@@ -108,10 +132,13 @@
},
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 || "鐧诲綍澶辫触");
@@ -124,6 +151,80 @@
}
});
},
+ 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 {
diff --git a/src/main/webapp/static/js/user/user.js b/src/main/webapp/static/js/user/user.js
index 84396c6..c12cb8d 100644
--- a/src/main/webapp/static/js/user/user.js
+++ b/src/main/webapp/static/js/user/user.js
@@ -2597,6 +2597,65 @@
}
]);
+ 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() : '';
@@ -2659,7 +2718,7 @@
}
function isSearchableField(field) {
- return !!field && field.kind !== 'image' && !field.textarea;
+ return !!field && field.kind !== 'image' && !field.textarea && field.searchable !== false;
}
function isSortableField(field) {
@@ -2716,7 +2775,9 @@
}
function createDefaultVisibleColumnKeys() {
- return fieldMeta.map(function (field) {
+ return fieldMeta.filter(function (field) {
+ return field.visible !== false;
+ }).map(function (field) {
return field.field;
});
}
@@ -3041,7 +3102,7 @@
},
editableFields: function () {
return this.fieldMeta.filter(function (field) {
- return !field.primaryKey;
+ return !field.primaryKey && field.editable !== false;
});
},
exportColumns: function () {
diff --git a/src/main/webapp/views/detail.html b/src/main/webapp/views/detail.html
index 4985baa..301d6b2 100644
--- a/src/main/webapp/views/detail.html
+++ b/src/main/webapp/views/detail.html
@@ -108,6 +108,65 @@
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;
@@ -123,18 +182,21 @@
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;
}
@@ -169,18 +231,77 @@
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>
@@ -194,7 +315,6 @@
<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"
@@ -229,6 +349,48 @@
<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>
@@ -271,6 +433,49 @@
</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銆丮icrosoft 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>
@@ -278,5 +483,5 @@
<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>
diff --git a/src/main/webapp/views/login.html b/src/main/webapp/views/login.html
index 2c28dae..1574206 100644
--- a/src/main/webapp/views/login.html
+++ b/src/main/webapp/views/login.html
@@ -273,14 +273,16 @@
.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;
@@ -288,14 +290,16 @@
.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;
}
@@ -357,6 +361,33 @@
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) {
@@ -494,6 +525,27 @@
</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>
@@ -520,5 +572,5 @@
<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>
diff --git a/src/main/webapp/views/user/user.html b/src/main/webapp/views/user/user.html
index 0c41eae..6a8cabe 100644
--- a/src/main/webapp/views/user/user.html
+++ b/src/main/webapp/views/user/user.html
@@ -665,6 +665,6 @@
<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>
--
Gitblit v1.9.1