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