#
Junjie
昨天 be1cd9e5b30097ca427a9c2b7b054b28854e410a
#
4个文件已添加
16个文件已修改
1452 ■■■■■ 已修改文件
src/main/java/com/zy/common/auth/MfaLoginTicketManager.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/MfaTotpUtil.java 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/QrCode.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/web/AuthController.java 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/controller/UserController.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/entity/User.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/mapper/UserMapper.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/UserService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/system/service/impl/UserServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/en-US/messages.properties 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/i18n/zh-CN/messages.properties 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/mapper/UserMapper.xml 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/detail/detail.js 207 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/login/login.js 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/user/user.js 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/detail.html 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/login.html 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/user/user.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/auth/MfaLoginTicketManager.java
New file
@@ -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;
        }
    }
}
src/main/java/com/zy/common/utils/MfaTotpUtil.java
New file
@@ -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");
    }
}
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);
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);
        if (requiresMfa(user)) {
        Map<String, Object> res = new HashMap<>();
        res.put("username", user.getUsername());
        res.put("token", token);
            res.put("mfaRequired", true);
            res.put("mfaTicket", mfaLoginTicketManager.create(user.getId()));
        return R.ok(res);
        }
        return R.ok(buildLoginSuccess(user));
    }
    @RequestMapping("/login/mfa.action")
    @ManagerAuth(value = ManagerAuth.Auth.NONE, memo = "MFA登录")
    public R loginMfaAction(String ticket, String code) {
        Long userId = mfaLoginTicketManager.getUserId(ticket);
        if (userId == null) {
            return new R(10004, i18nMessageService.getMessage("response.user.mfaTicketExpired"));
        }
        User user = userService.getByIdWithMfa(userId);
        if (Cools.isEmpty(user)) {
            mfaLoginTicketManager.remove(ticket);
            return new R(10001, i18nMessageService.getMessage("response.user.notFound"));
        }
        if (user.getStatus() != 1) {
            mfaLoginTicketManager.remove(ticket);
            return new R(10002, i18nMessageService.getMessage("response.user.disabled"));
        }
        if (!requiresMfa(user)) {
            mfaLoginTicketManager.remove(ticket);
            return new R(10005, i18nMessageService.getMessage("response.user.mfaNotEnabled"));
        }
        if (!MfaTotpUtil.verifyCode(user.getMfaSecret(), code, 1)) {
            return new R(10006, i18nMessageService.getMessage("response.user.mfaCodeMismatch"));
        }
        mfaLoginTicketManager.remove(ticket);
        return R.ok(buildLoginSuccess(user));
    }
    @RequestMapping("/code/switch.action")
@@ -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());
    }
src/main/java/com/zy/system/config/UserMfaSchemaInitializer.java
New file
@@ -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;
    }
}
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 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;
    }
}
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;
    }
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);
}
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);
}
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);
    }
}
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
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=当前账号未开通MFA使用权限
response.user.mfaNotEnabled=当前账号未启用MFA
response.user.mfaCodeMismatch=MFA验证码错误
response.user.mfaTicketExpired=MFA登录票据已失效,请重新输入账号密码
response.system.licenseExpired=许可证已失效
response.common.systemError=系统异常,请稍后重试
response.common.methodNotAllowed=请求方式不支持
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>
src/main/resources/sql/20260311_add_mfa_columns_to_sys_user.sql
New file
@@ -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 ''是否已启用MFA'' AFTER mfa_allow',
  'SELECT ''column mfa_enabled already exists'' '
);
PREPARE stmt_mfa_enabled FROM @add_mfa_enabled_sql;
EXECUTE stmt_mfa_enabled;
DEALLOCATE PREPARE stmt_mfa_enabled;
SET @mfa_secret_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_user'
    AND COLUMN_NAME = 'mfa_secret'
);
SET @add_mfa_secret_sql := IF(
  @mfa_secret_exists = 0,
  'ALTER TABLE sys_user ADD COLUMN mfa_secret VARCHAR(128) NULL COMMENT ''MFA密钥'' AFTER mfa_enabled',
  'SELECT ''column mfa_secret already exists'' '
);
PREPARE stmt_mfa_secret FROM @add_mfa_secret_sql;
EXECUTE stmt_mfa_secret;
DEALLOCATE PREPARE stmt_mfa_secret;
SET @mfa_bound_time_exists := (
  SELECT COUNT(1)
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @current_db
    AND TABLE_NAME = 'sys_user'
    AND COLUMN_NAME = 'mfa_bound_time'
);
SET @add_mfa_bound_time_sql := IF(
  @mfa_bound_time_exists = 0,
  'ALTER TABLE sys_user ADD COLUMN mfa_bound_time DATETIME NULL COMMENT ''MFA绑定时间'' AFTER mfa_secret',
  'SELECT ''column mfa_bound_time already exists'' '
);
PREPARE stmt_mfa_bound_time FROM @add_mfa_bound_time_sql;
EXECUTE stmt_mfa_bound_time;
DEALLOCATE PREPARE stmt_mfa_bound_time;
SHOW COLUMNS FROM sys_user LIKE 'mfa_allow';
SHOW COLUMNS FROM sys_user LIKE 'mfa_enabled';
SHOW COLUMNS FROM sys_user LIKE 'mfa_secret';
SHOW COLUMNS FROM sys_user LIKE 'mfa_bound_time';
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("当前账号未开通MFA使用权限");
                    return;
                }
                this.mfaDialogMode = "enable";
                this.resetMfaDialogState();
                this.mfaDialogVisible = true;
                this.loadMfaSetup();
            },
            openMfaDisableDialog: function () {
                this.mfaDialogMode = "disable";
                this.resetMfaDialogState();
                this.mfaDialogVisible = true;
            },
            closeMfaDialog: function () {
                this.mfaDialogVisible = false;
                this.resetMfaDialogState();
            },
            resetMfaDialogState: function () {
                this.mfaSubmitting = false;
                this.mfaSetupLoading = false;
                this.mfaForm = {
                    currentPassword: "",
                    code: ""
                };
                this.mfaSetup = {
                    secret: "",
                    qrCode: "",
                    otpAuth: ""
                };
                if (this.$refs.mfaForm) {
                    this.$refs.mfaForm.clearValidate();
                }
            },
            loadMfaSetup: function () {
                var vm = this;
                vm.mfaSetupLoading = true;
                $.ajax({
                    url: baseUrl + "/user/mfa/setup/auth",
                    headers: { token: localStorage.getItem("token") },
                    method: "POST",
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "MFA配置加载失败");
                            return;
                        }
                        vm.mfaSetup = Object.assign({}, vm.mfaSetup, res.data || {});
                    },
                    error: function () {
                        vm.$message.error("MFA配置加载失败");
                    },
                    complete: function () {
                        vm.mfaSetupLoading = false;
                    }
                });
            },
            handleMfaSubmit: function () {
                var vm = this;
                if (!vm.$refs.mfaForm) {
                    return;
                }
                vm.$refs.mfaForm.validate(function (valid) {
                    if (!valid) {
                        return false;
                    }
                    if (vm.mfaDialogMode === "enable" && !vm.mfaSetup.secret) {
                        vm.$message.error("MFA密钥尚未生成,请稍后重试");
                        return false;
                    }
                    vm.submitMfaAction();
                    return true;
                });
            },
            submitMfaAction: function () {
                var vm = this;
                vm.mfaSubmitting = true;
                $.ajax({
                    url: baseUrl + (vm.mfaDialogMode === "enable" ? "/user/mfa/enable/auth" : "/user/mfa/disable/auth"),
                    headers: { token: localStorage.getItem("token") },
                    data: {
                        currentPassword: hex_md5(vm.mfaForm.currentPassword),
                        code: vm.mfaForm.code,
                        secret: vm.mfaSetup.secret
                    },
                    method: "POST",
                    success: function (res) {
                        if (handleForbidden(res)) {
                            return;
                        }
                        if (Number(res.code) !== 200) {
                            vm.$message.error(res.msg || "MFA操作失败");
                            return;
                        }
                        vm.$message.success(vm.mfaDialogMode === "enable" ? "MFA已启用" : "MFA已停用");
                        vm.closeMfaDialog();
                        vm.loadDetail();
                    },
                    error: function () {
                        vm.$message.error("MFA操作失败");
                    },
                    complete: function () {
                        vm.mfaSubmitting = false;
                    }
                });
            },
            copySecret: function () {
                var vm = this;
                var text = vm.mfaSetup.secret || "";
                if (!text) {
                    return;
                }
                if (navigator.clipboard && navigator.clipboard.writeText) {
                    navigator.clipboard.writeText(text).then(function () {
                        vm.$message.success("密钥已复制");
                    }).catch(function () {
                        vm.fallbackCopy(text);
                    });
                    return;
                }
                vm.fallbackCopy(text);
            },
            fallbackCopy: function (text) {
                try {
                    var textarea = document.createElement("textarea");
                    textarea.value = text;
                    textarea.style.position = "fixed";
                    textarea.style.opacity = "0";
                    document.body.appendChild(textarea);
                    textarea.select();
                    document.execCommand("copy");
                    document.body.removeChild(textarea);
                    this.$message.success("密钥已复制");
                } catch (err) {
                    this.$message.error("复制失败");
                }
            },
            handleSave: function () {
                var vm = this;
                vm.$refs.profileForm.validate(function (valid) {
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 {
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 () {
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、Microsoft Authenticator 等身份验证器扫描二维码,再输入当前密码和 6 位动态码完成绑定。</div>
            <div class="mfa-qr-wrap" v-loading="mfaSetupLoading">
                <img v-if="mfaSetup.qrCode" :src="mfaSetup.qrCode" alt="MFA QR Code">
                <span v-else>二维码生成中...</span>
            </div>
            <div class="mfa-secret-row">
                <el-input :value="mfaSetup.secret" readonly placeholder="MFA密钥"></el-input>
                <el-button plain @click="copySecret">复制密钥</el-button>
            </div>
        </div>
        <div v-else class="mfa-setup">
            <div class="mfa-setup-tip">停用 MFA 前,请输入当前密码和身份验证器中的 6 位动态码进行确认。</div>
        </div>
        <el-form
            ref="mfaForm"
            class="password-form"
            :model="mfaForm"
            :rules="mfaRules"
            label-width="112px"
            size="small"
            @submit.native.prevent>
            <el-form-item label="当前密码" prop="currentPassword">
                <el-input v-model="mfaForm.currentPassword" type="password" show-password autocomplete="off"></el-input>
            </el-form-item>
            <el-form-item label="动态验证码" prop="code">
                <el-input v-model.trim="mfaForm.code" maxlength="6" autocomplete="off" placeholder="请输入6位验证码"></el-input>
            </el-form-item>
            <div class="mfa-footer">
                <el-button @click="closeMfaDialog">关闭</el-button>
                <el-button type="primary" :loading="mfaSubmitting" @click="handleMfaSubmit">保存</el-button>
            </div>
        </el-form>
    </el-dialog>
</div>
</body>
<script type="text/javascript" src="../static/js/jquery/jquery-3.3.1.min.js"></script>
@@ -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>
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>
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>