diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java index a311f98..b6a4d4d 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java @@ -117,6 +117,11 @@ public interface CommonConstant { /** 登录二维码token */ String LOGIN_QRCODE_TOKEN = "LQT:"; + /** + * 登录页图形验证码全局开关(Redis 存储 true/false 字符串;存在时覆盖 jeecg.firewall.enable-login-captcha,与侧边栏项目配置同步) + */ + String SYS_LOGIN_CAPTCHA_ENABLED = "sys:login:captcha:enabled"; + /** * 0:一级菜单 diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java index ec14ff2..7d44d62 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java @@ -91,6 +91,7 @@ public class ShiroConfig { filterChainDefinitionMap.put("/sys/smsCheckCaptcha", "anon"); //短信次数发送太多验证码排除 filterChainDefinitionMap.put("/sys/login", "anon"); //登录接口排除 filterChainDefinitionMap.put("/sys/mLogin", "anon"); //登录接口排除 + filterChainDefinitionMap.put("/sys/loginCaptchaConfig", "anon"); // 登录页是否需图形验证码(未登录可访问) filterChainDefinitionMap.put("/sys/logout", "anon"); //登出接口排除 filterChainDefinitionMap.put("/sys/thirdLogin/**", "anon"); //第三方登录 filterChainDefinitionMap.put("/sys/getEncryptedString", "anon"); //获取加密串 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java index b26b7e8..1e75638 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java @@ -12,6 +12,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresRoles; import org.jeecg.common.api.vo.Result; import org.jeecg.common.constant.CacheConstant; @@ -737,6 +738,46 @@ public class LoginController { } return Result.ok(); } + + /** + * 登录页是否启用图形验证码(未登录可访问)。优先 Redis 全局配置(与侧边栏项目配置同步),否则沿用 jeecg.firewall.enable-login-captcha + */ + @IgnoreAuth + @GetMapping("/loginCaptchaConfig") + @Operation(summary = "登录页是否启用图形验证码") + public Result loginCaptchaConfig() { + return Result.OK(isLoginCaptchaRequired()); + } + + /** + * 保存登录页图形验证码开关(写入 Redis,全站生效;需权限 system:project:setting:loginCaptcha) + */ + @PostMapping("/setLoginCaptchaConfig") + @Operation(summary = "保存登录页图形验证码开关") + @RequiresPermissions("system:project:setting:loginCaptcha") + public Result setLoginCaptchaConfig(@RequestBody JSONObject body) { + Boolean enabled = body.getBoolean("enabled"); + if (enabled == null) { + return Result.error("参数 enabled 不能为空"); + } + redisUtil.set(CommonConstant.SYS_LOGIN_CAPTCHA_ENABLED, enabled.toString()); + baseCommonService.addLog("保存登录验证码开关: " + enabled, CommonConstant.LOG_TYPE_2, CommonConstant.OPERATE_TYPE_3); + return Result.OK("保存成功"); + } + + /** + * 是否需要在登录时校验图形验证码(Redis 覆盖 > firewall 配置) + */ + private boolean isLoginCaptchaRequired() { + Object v = redisUtil.get(CommonConstant.SYS_LOGIN_CAPTCHA_ENABLED); + if (v != null) { + return Boolean.parseBoolean(v.toString()); + } + return jeecgBaseConfig.getFirewall() == null + || jeecgBaseConfig.getFirewall().getEnableLoginCaptcha() == null + || Boolean.TRUE.equals(jeecgBaseConfig.getFirewall().getEnableLoginCaptcha()); + } + /** * 登录二维码 */ @@ -917,9 +958,9 @@ public class LoginController { * 校验验证码工具方法,校验失败直接返回Result,校验通过返回realKey */ private String validateCaptcha(SysLoginModel sysLoginModel, Result result) { - // 判断是否启用登录验证码校验 - if (jeecgBaseConfig.getFirewall() != null && Boolean.FALSE.equals(jeecgBaseConfig.getFirewall().getEnableLoginCaptcha())) { - log.warn("关闭了登录验证码校验,跳过验证码校验!"); + // 是否启用登录验证码(含 Redis 全局开关与 firewall 配置) + if (!isLoginCaptchaRequired()) { + log.warn("已关闭登录验证码校验(Redis 全局或 firewall),跳过验证码校验!"); return "LoginWithoutVerifyCode"; } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCheckRuleServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCheckRuleServiceImpl.java index 300a57f..1bbbfba 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCheckRuleServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCheckRuleServiceImpl.java @@ -52,6 +52,11 @@ public class SysCheckRuleServiceImpl extends ServiceImpl( + { url: '/sys/loginCaptchaConfig' }, + { withToken: false, errorMessageMode: 'none' }, + ); +} + +/** + * 保存登录验证码开关(写入后端 Redis,全站生效;需登录且具备 system:project:setting:loginCaptcha) + */ +export function setLoginCaptchaServerConfig(enabled: boolean) { + return defHttp.post( + { url: '/sys/setLoginCaptchaConfig', data: { enabled } }, + { errorMessageMode: 'message' }, + ); +} /** * @description: 获取短信验证码 */ diff --git a/jeecgboot-vue3/src/hooks/setting/useRootSetting.ts b/jeecgboot-vue3/src/hooks/setting/useRootSetting.ts index 508fae7..aa511a9 100644 --- a/jeecgboot-vue3/src/hooks/setting/useRootSetting.ts +++ b/jeecgboot-vue3/src/hooks/setting/useRootSetting.ts @@ -4,6 +4,7 @@ import { computed } from 'vue'; import { useAppStore } from '/@/store/modules/app'; import { ContentEnum, ThemeEnum } from '/@/enums/appEnum'; +import projectSetting from '/@/settings/projectSetting'; type RootSetting = Omit; @@ -45,6 +46,18 @@ export function useRootSetting() { const getGrayMode = computed(() => appStore.getProjectConfig.grayMode); // 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示 const getAiIconShow = computed(() => appStore.getProjectConfig.aiIconShow); + /** 与 projectSetting 默认合并:旧缓存无该字段时不再误判为「开启验证码」;兼容异常字符串缓存 */ + const getLoginCaptchaEnabled = computed(() => { + const v = appStore.getProjectConfig.loginCaptchaEnabled; + if (v === undefined || v === null) { + return projectSetting.loginCaptchaEnabled; + } + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + return s === 'true' || s === '1'; + } + return Boolean(v); + }); const getLockTime = computed(() => appStore.getProjectConfig.lockTime); const getShowDarkModeToggle = computed(() => appStore.getProjectConfig.showDarkModeToggle); @@ -86,5 +99,6 @@ export function useRootSetting() { setDarkMode, getShowDarkModeToggle, getAiIconShow, + getLoginCaptchaEnabled, }; } diff --git a/jeecgboot-vue3/src/layouts/default/setting/SettingDrawer.tsx b/jeecgboot-vue3/src/layouts/default/setting/SettingDrawer.tsx index ff43bca..010b1c3 100644 --- a/jeecgboot-vue3/src/layouts/default/setting/SettingDrawer.tsx +++ b/jeecgboot-vue3/src/layouts/default/setting/SettingDrawer.tsx @@ -1,4 +1,5 @@ import { defineComponent, computed, unref } from 'vue'; +import { usePermission } from '/@/hooks/web/usePermission'; import { BasicDrawer } from '/@/components/Drawer/index'; import { Divider } from 'ant-design-vue'; import { TypePicker, ThemeColorPicker, SettingFooter, SwitchItem, SelectItem, InputNumberItem } from './components'; @@ -47,8 +48,13 @@ export default defineComponent({ getShowDarkModeToggle, getThemeColor, getAiIconShow, + getLoginCaptchaEnabled, } = useRootSetting(); + const { hasPermission } = usePermission(); + /** 项目配置中「验证码登录」开关仅对有权限的用户展示 */ + const showLoginCaptchaSetting = computed(() => hasPermission('system:project:setting:loginCaptcha')); + const { getOpenPageLoading, getBasicTransition, getEnableTransition, getOpenNProgress } = useTransitionSetting(); const { @@ -318,6 +324,13 @@ export default defineComponent({ + {unref(showLoginCaptchaSetting) && ( + + )} ); } diff --git a/jeecgboot-vue3/src/layouts/default/setting/components/SwitchItem.vue b/jeecgboot-vue3/src/layouts/default/setting/components/SwitchItem.vue index 09962f4..b00a4a0 100644 --- a/jeecgboot-vue3/src/layouts/default/setting/components/SwitchItem.vue +++ b/jeecgboot-vue3/src/layouts/default/setting/components/SwitchItem.vue @@ -41,10 +41,14 @@ const { t } = useI18n(); const getBindValue = computed(() => { - return props.def ? { checked: props.def } : {}; + // 必须为 false 时也绑定 checked,否则 def===false 会被当成无绑定,开关与持久化异常 + if (props.def === undefined || props.def === null) { + return {}; + } + return { checked: props.def }; }); - function handleChange(e: ChangeEvent) { - props.event && baseHandler(props.event, e); + function handleChange(checked: boolean) { + props.event && baseHandler(props.event, checked); } return { prefixCls, diff --git a/jeecgboot-vue3/src/layouts/default/setting/enum.ts b/jeecgboot-vue3/src/layouts/default/setting/enum.ts index 8111d8e..d8aebb7 100644 --- a/jeecgboot-vue3/src/layouts/default/setting/enum.ts +++ b/jeecgboot-vue3/src/layouts/default/setting/enum.ts @@ -53,6 +53,8 @@ export enum HandlerEnum { OPEN_PAGE_LOADING, OPEN_ROUTE_TRANSITION, AI_ICON_SHOW, + /** 登录页图形验证码开关 */ + LOGIN_CAPTCHA_ENABLED, } // 标签页样式 diff --git a/jeecgboot-vue3/src/layouts/default/setting/handler.ts b/jeecgboot-vue3/src/layouts/default/setting/handler.ts index e70d0d4..0df7816 100644 --- a/jeecgboot-vue3/src/layouts/default/setting/handler.ts +++ b/jeecgboot-vue3/src/layouts/default/setting/handler.ts @@ -3,6 +3,8 @@ import { updateHeaderBgColor, updateSidebarBgColor } from '/@/logics/theme/updat import { updateColorWeak } from '/@/logics/theme/updateColorWeak'; import { updateGrayMode } from '/@/logics/theme/updateGrayMode'; +import { message } from 'ant-design-vue'; +import { setLoginCaptchaServerConfig } from '/@/api/sys/user'; import { useAppStore } from '/@/store/modules/app'; import { ProjectConfig } from '/#/config'; import { changeTheme } from '/@/logics/theme'; @@ -80,6 +82,12 @@ export function baseHandler(event: HandlerEnum, value: any) { const appStore = useAppStore(); const config = handler(event, value); appStore.setProjectConfig(config); + // 验证码登录开关同步到后端 Redis,登录页仅依赖 GET /sys/loginCaptchaConfig + if (event === HandlerEnum.LOGIN_CAPTCHA_ENABLED) { + setLoginCaptchaServerConfig(!!value).catch(() => { + message.error('验证码开关同步到后端失败,请检查网络或是否具备 system:project:setting:loginCaptcha 权限'); + }); + } if (event === HandlerEnum.CHANGE_THEME) { updateHeaderBgColor(); updateSidebarBgColor(); @@ -210,6 +218,8 @@ export function handler(event: HandlerEnum, value: any): DeepPartial { + if (persistedHadLoginCaptchaKey) { + return; + } + appStore.setProjectConfig({ loginCaptchaEnabled: parseLoginCaptchaEnabledFromServer(enabled) }); + }) + .catch(() => {}); // init dark mode updateDarkTheme(darkMode); diff --git a/jeecgboot-vue3/src/settings/projectSetting.ts b/jeecgboot-vue3/src/settings/projectSetting.ts index 40e1522..2e56585 100644 --- a/jeecgboot-vue3/src/settings/projectSetting.ts +++ b/jeecgboot-vue3/src/settings/projectSetting.ts @@ -71,6 +71,9 @@ const setting: ProjectConfig = { // ai图标显示 aiIconShow: false, + // 登录页是否展示图形验证码(未写入本地配置时以此为准;需要验证码时在项目配置中开启) + loginCaptchaEnabled: false, + // 头部配置 headerSetting: { // 背景色 diff --git a/jeecgboot-vue3/src/utils/sys/sysPasswordRules.ts b/jeecgboot-vue3/src/utils/sys/sysPasswordRules.ts new file mode 100644 index 0000000..ee6497b --- /dev/null +++ b/jeecgboot-vue3/src/utils/sys/sysPasswordRules.ts @@ -0,0 +1,57 @@ +/** + * 系统登录密码校验:与「低代码开发 → 系统校验规则」中规则编码 sys_login_password 对应, + * 服务端 /sys/checkRule/checkByCode 按 ruleJson 校验(多条全局规则顺序执行);库表见 Flyway V3.9.2_1 / V3.9.2_2。 + */ +import type { Rule } from '/@/components/Form/src/types/form'; +import { validateCheckRule } from '/@/views/system/checkRule/check.rule.api'; + +/** 与 sys_check_rule.rule_code 一致,勿随意修改 */ +export const SYS_LOGIN_PASSWORD_CHECK_RULE_CODE = 'sys_login_password'; + +/** 与库中多条规则合并效果一致(规则不存在或接口失败时兜底) */ +export const SYS_LOGIN_PASSWORD_PATTERN = + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+\-={}[\]:;"'<>,.?/]).{8,}$/; + +export const SYS_LOGIN_PASSWORD_PATTERN_MESSAGE = + '密码由 8 位及以上数字、大小写字母和特殊符号组成!'; + +export const SYS_LOGIN_PASSWORD_REQUIRED_MESSAGE = '请输入登录密码'; + +/** 适用于 FormSchema.rules:必填 + 调用系统校验规则(失败时本地正则兜底) */ +export function getSysLoginPasswordRules(): Rule[] { + return [ + { + required: true, + message: SYS_LOGIN_PASSWORD_REQUIRED_MESSAGE, + }, + { + async validator(_rule, value: string) { + if (value === undefined || value === null || String(value).trim() === '') { + return Promise.reject(SYS_LOGIN_PASSWORD_REQUIRED_MESSAGE); + } + const str = String(value); + try { + const res = (await validateCheckRule(SYS_LOGIN_PASSWORD_CHECK_RULE_CODE, str)) as { + success?: boolean; + message?: string; + }; + if (res?.success) { + return Promise.resolve(); + } + const msg = String(res?.message || ''); + if (msg.includes('不存在') || msg.includes('该编码')) { + return SYS_LOGIN_PASSWORD_PATTERN.test(str) + ? Promise.resolve() + : Promise.reject(SYS_LOGIN_PASSWORD_PATTERN_MESSAGE); + } + return Promise.reject(msg || SYS_LOGIN_PASSWORD_PATTERN_MESSAGE); + } catch { + return SYS_LOGIN_PASSWORD_PATTERN.test(str) + ? Promise.resolve() + : Promise.reject(SYS_LOGIN_PASSWORD_PATTERN_MESSAGE); + } + }, + trigger: 'change', + }, + ] as Rule[]; +} diff --git a/jeecgboot-vue3/src/views/sys/login/LoginForm.vue b/jeecgboot-vue3/src/views/sys/login/LoginForm.vue index ab2e8e4..bf3ffdc 100644 --- a/jeecgboot-vue3/src/views/sys/login/LoginForm.vue +++ b/jeecgboot-vue3/src/views/sys/login/LoginForm.vue @@ -8,25 +8,27 @@ - - - - - - - - - - - - - - + + @@ -98,7 +100,7 @@ import { useUserStore } from '/@/store/modules/user'; import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from './useLogin'; import { useDesign } from '/@/hooks/web/useDesign'; - import { getCodeInfo } from '/@/api/sys/user'; + import { getCodeInfo, getLoginCaptchaServerConfig } from '/@/api/sys/user'; import { encryptAESCBC } from '/@/utils/cipher'; const ACol = Col; @@ -113,6 +115,26 @@ const { prefixCls } = useDesign('login'); const userStore = useUserStore(); + /** + * 是否展示验证码(与后端是否校验图形码一致)。 + * 浏览器无法直连 Redis:调用 GET /sys/loginCaptchaConfig,由后端读取 Redis 全局开关,无值时再回落 jeecg.firewall.enable-login-captcha。 + * null 表示尚未拉取完成,不展示验证码区域,避免误请求 randomImage。 + */ + const serverRequiresCaptcha = ref(null); + + const captchaVisible = computed(() => serverRequiresCaptcha.value === true); + + /** 兼容接口 result 为 boolean / 字符串 */ + function parseCaptchaRequired(raw: unknown): boolean { + if (raw === true || raw === 'true' || raw === 1 || raw === '1') { + return true; + } + if (raw === false || raw === 'false' || raw === 0 || raw === '0') { + return false; + } + return false; + } + const { setLoginState, getLoginState } = useLoginState(); const { getFormRules } = useFormRules(); @@ -132,6 +154,13 @@ checkKey: null, }); + function clearCaptchaUiState() { + formData.inputCode = ''; + randCodeData.randCodeImage = ''; + randCodeData.requestCodeSuccess = false; + randCodeData.checkKey = null; + } + const { validForm } = useFormValid(formRef); //onKeyStroke('Enter', handleLogin); @@ -146,15 +175,16 @@ // 密码使用AES加密传输 const encryptedPassword = encryptAESCBC(data.password); - const { userInfo } = await userStore.login( - toRaw({ - password: encryptedPassword, - username: data.account, - captcha: data.inputCode, - checkKey: randCodeData.checkKey, - mode: 'none', //不要默认的错误提示 - }) - ); + const loginPayload: Recordable = { + password: encryptedPassword, + username: data.account, + mode: 'none', //不要默认的错误提示 + }; + if (unref(captchaVisible)) { + loginPayload.captcha = data.inputCode; + loginPayload.checkKey = randCodeData.checkKey; + } + const { userInfo } = await userStore.login(toRaw(loginPayload)); if (userInfo) { notification.success({ message: t('sys.login.loginSuccessTitle'), @@ -169,7 +199,9 @@ duration: 3, }); loading.value = false; - handleChangeCheckCode(); + if (unref(captchaVisible)) { + handleChangeCheckCode(); + } } } function handleChangeCheckCode() { @@ -189,8 +221,17 @@ function onThirdLogin(type) { thirdModalRef.value.onThirdLogin(type); } - //初始化验证码 - onMounted(() => { + onMounted(async () => { + try { + const enabled = await getLoginCaptchaServerConfig(); + serverRequiresCaptcha.value = parseCaptchaRequired(enabled); + } catch { + serverRequiresCaptcha.value = false; + } + if (!unref(captchaVisible)) { + clearCaptchaUiState(); + return; + } handleChangeCheckCode(); }); diff --git a/jeecgboot-vue3/src/views/system/checkRule/CheckRuleModal.vue b/jeecgboot-vue3/src/views/system/checkRule/CheckRuleModal.vue index 466c3ba..4b9cfe5 100644 --- a/jeecgboot-vue3/src/views/system/checkRule/CheckRuleModal.vue +++ b/jeecgboot-vue3/src/views/system/checkRule/CheckRuleModal.vue @@ -23,7 +23,12 @@ :columns="columns2" > @@ -75,16 +80,14 @@ let ruleJson = data.record.ruleJson; if (ruleJson) { - let ruleList = JSON.parse(ruleJson); - // 筛选出全局规则和局部规则 - let global: any[] = [], - design: any[] = [], - priority = '1'; - ruleList.forEach((rule) => { + const ruleList = JSON.parse(ruleJson); + const global: any[] = []; + const design: any[] = []; + ruleList.forEach((rule: any) => { if (rule.digits === '*') { - global.push(Object.assign(rule, { priority })); + const p = rule.priority != null && String(rule.priority) !== '' ? String(rule.priority) : '1'; + global.push({ ...rule, priority: p }); } else { - priority = '0'; design.push(rule); } }); @@ -132,20 +135,26 @@ if (tableData2 && tableData2.length > 0) { globalValues = tableData2; } - // 整合两个子表的数据 - let firstGlobal: any[] = [], - afterGlobal: any[] = []; + // 整合两个子表的数据(priority:1 优先、0 最后、2 不执行;2 放末尾仅便于存储,后端会跳过) + const firstGlobal: any[] = []; + const afterGlobal: any[] = []; + const inactiveGlobal: any[] = []; for (let i = 0; i < globalValues.length; i++) { - let v: any = globalValues[i]; + const v: any = globalValues[i]; v.digits = '*'; - if (v.priority === '1') { + const p = String(v.priority ?? '1'); + if (p === '1') { firstGlobal.push(v); - } else { + } else if (p === '0') { afterGlobal.push(v); + } else if (p === '2') { + inactiveGlobal.push(v); + } else { + firstGlobal.push(v); } } - let concatValues = firstGlobal.concat(designValues).concat(afterGlobal); - let subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message')); + const concatValues = firstGlobal.concat(designValues).concat(afterGlobal).concat(inactiveGlobal); + const subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message', 'priority')); // 生成 formData,用于传入后台 let ruleJson = JSON.stringify(subValues); let formData = Object.assign({}, mainData, { ruleJson }); @@ -226,6 +235,7 @@ options: [ { title: '优先运行', value: '1' }, { title: '最后运行', value: '0' }, + { title: '不执行', value: '2' }, ], validateRules: [], }, diff --git a/jeecgboot-vue3/src/views/system/loginmini/MiniLogin.vue b/jeecgboot-vue3/src/views/system/loginmini/MiniLogin.vue index 79a10ac..e0bb5df 100644 --- a/jeecgboot-vue3/src/views/system/loginmini/MiniLogin.vue +++ b/jeecgboot-vue3/src/views/system/loginmini/MiniLogin.vue @@ -46,7 +46,8 @@ -
+ +
@@ -174,7 +175,7 @@