修复权限:隔离教师工作台与学员中心菜单自服务可见性

This commit is contained in:
wangzhibiao committed 2026-09-22 11:59:38 +08:00
1 parent 2b1fcdb260
commit ecfd38be17
15 files changed
+929 -17

No files matched your search

@@ -92,6 +92,9 @@ public class SysLoginController
return ajax;
}
@Autowired(required = false)
private com.ruoyi.train.service.ITrainingIdentityService identityService;
/**
* 获取路由信息
*
@@ -102,8 +105,99 @@ public class SysLoginController
{
Long userId = SecurityUtils.getUserId();
List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
if (menus != null && !menus.isEmpty())
{
menus = filterSelfServiceMenus(menus, userId);
}
return AjaxResult.success(menuService.buildMenus(menus));
}
/**
* 根据用户真实业务身份过滤角色专属自服务菜单(教师工作台、学员中心)。
* 即使是超级管理员,管理权限亦不等于业务主体身份;无真实档案绑定则不可见。
*/
private List<SysMenu> filterSelfServiceMenus(List<SysMenu> menus, Long userId)
{
boolean hasTeacherIdentity = false;
boolean hasStudentIdentity = false;
if (identityService != null && userId != null)
{
try
{
com.ruoyi.train.domain.TrainTeacher teacher = identityService.getTeacherByUserId(userId);
hasTeacherIdentity = (teacher != null && (teacher.getStatus() == null || teacher.getStatus() != 0));
}
catch (Exception ignored) {}
try
{
com.ruoyi.train.domain.TrainStudent student = identityService.getStudentByUserId(userId);
hasStudentIdentity = (student != null && (student.getStatus() == null || student.getStatus() != 0));
}
catch (Exception ignored) {}
}
else if (userId != null && !SecurityUtils.isAdmin(userId))
{
// 无身份服务依赖时,降级检查角色绑定,但管理角色绝不赋予自服务
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser != null && loginUser.getUser() != null && loginUser.getUser().getRoles() != null)
{
for (com.ruoyi.common.core.domain.entity.SysRole r : loginUser.getUser().getRoles())
{
if ("train_teacher".equals(r.getRoleKey()))
{
hasTeacherIdentity = true;
}
if ("train_student".equals(r.getRoleKey()))
{
hasStudentIdentity = true;
}
}
}
}
List<SysMenu> result = new java.util.ArrayList<>();
for (SysMenu m : menus)
{
if (isSelfServiceAllowed(m, hasTeacherIdentity, hasStudentIdentity))
{
if (m.getChildren() != null && !m.getChildren().isEmpty())
{
m.setChildren(filterSelfServiceMenus(m.getChildren(), userId));
}
result.add(m);
}
}
return result;
}
private boolean isSelfServiceAllowed(SysMenu m, boolean hasTeacherIdentity, boolean hasStudentIdentity)
{
String component = m.getComponent();
String path = m.getPath();
String perms = m.getPerms();
boolean isTeacherWorkbench = "train/me/teacher/index".equals(component)
|| "train:me:teacher".equals(perms)
|| ("teacher".equals(path) && "train/me/teacher/index".equals(component));
if (isTeacherWorkbench && !hasTeacherIdentity)
{
return false;
}
boolean isStudentCenter = "train/me/student/index".equals(component)
|| "train:me:student".equals(perms)
|| ("student".equals(path) && "train/me/student/index".equals(component));
if (isStudentCenter && !hasStudentIdentity)
{
return false;
}
return true;
}
// 检查初始密码是否提醒修改
public boolean initPasswordIsModify(Date pwdUpdateDate)
@@ -0,0 +1,354 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.common.core.domain.entity.SysMenu;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.service.ITrainingIdentityService;
import com.ruoyi.web.controller.system.SysLoginController;
/**
* 角色专属自服务菜单可见性与未授权访问阻断契约测试。
*
* 核心规则:
* 1. 管理权限 != 业务主体身份。
* 2. admin / train_admin 无教师档案时,教师工作台菜单 HIDDEN。
* 3. admin / train_admin 无学员档案时,学员中心菜单 HIDDEN。
* 4. 具备真实教师身份的用户,教师工作台 VISIBLE,学员中心 HIDDEN。
* 5. 具备真实学员身份的用户,学员中心 VISIBLE,教师工作台 HIDDEN。
* 6. 未授权用户直接调用 /train/me/teacher 或 /train/me/student 仍然被 403 严格拦截。
* 7. 前端请求层具备 403 防风暴机制,页面初始化在身份失败时截断后续请求。
*/
public class RoleNavigationVisibilityContractTest
{
private SysLoginController loginController;
private ITrainingIdentityService mockIdentityService;
private Long currentAdminUserId = 1L;
private Long currentTrainAdminUserId = 2L;
private Long currentTeacherUserId = 101L;
private Long currentStudentUserId = 201L;
private Long currentDualUserId = 301L;
private static final String PROJECT_ROOT = findProjectRoot();
private static String findProjectRoot()
{
File cur = new File(".").getAbsoluteFile();
while (cur != null)
{
if (new File(cur, "build_ui").isDirectory() && new File(cur, "RuoYi-Vue").isDirectory())
{
return cur.getAbsolutePath();
}
cur = cur.getParentFile();
}
return new File(".").getAbsolutePath();
}
@Before
public void setUp() throws Exception
{
loginController = new SysLoginController();
mockIdentityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("getTeacherByUserId".equals(name))
{
Long uid = (Long) args[0];
if (currentTeacherUserId.equals(uid) || currentDualUserId.equals(uid))
{
TrainTeacher t = new TrainTeacher();
t.settId("T_MOCK_101");
t.settName("测试讲师");
t.setStatus(1);
return t;
}
return null;
}
if ("getStudentByUserId".equals(name))
{
Long uid = (Long) args[0];
if (currentStudentUserId.equals(uid) || currentDualUserId.equals(uid))
{
TrainStudent s = new TrainStudent();
s.setsId("S_MOCK_201");
s.setsName("测试学员");
s.setStatus(1);
return s;
}
return null;
}
if ("assertTeacherIdentity".equals(name))
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403);
}
if ("assertStudentIdentity".equals(name))
{
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人中心", 403);
}
return null;
}
);
Field field = SysLoginController.class.getDeclaredField("identityService");
field.setAccessible(true);
field.set(loginController, mockIdentityService);
}
private List<SysMenu> createMockMenuTree()
{
List<SysMenu> list = new ArrayList<>();
// 1. 人员管理(包含教师信息与学员信息子菜单)
SysMenu peopleMgmt = new SysMenu();
peopleMgmt.setMenuId(2159L);
peopleMgmt.setMenuName("人员管理");
peopleMgmt.setPath("people");
peopleMgmt.setComponent("Layout");
List<SysMenu> peopleChildren = new ArrayList<>();
SysMenu teacherInfo = new SysMenu();
teacherInfo.setMenuId(2155L);
teacherInfo.setMenuName("教师信息");
teacherInfo.setPath("teacher");
teacherInfo.setComponent("train/teacher");
peopleChildren.add(teacherInfo);
SysMenu studentInfo = new SysMenu();
studentInfo.setMenuId(2156L);
studentInfo.setMenuName("学员信息");
studentInfo.setPath("student");
studentInfo.setComponent("train/student");
peopleChildren.add(studentInfo);
peopleMgmt.setChildren(peopleChildren);
list.add(peopleMgmt);
// 2. 教师工作台(自服务顶级菜单)
SysMenu teacherWorkbench = new SysMenu();
teacherWorkbench.setMenuId(2239L);
teacherWorkbench.setMenuName("教师工作台");
teacherWorkbench.setPath("teacher");
teacherWorkbench.setComponent("train/me/teacher/index");
teacherWorkbench.setPerms("train:me:teacher");
list.add(teacherWorkbench);
// 3. 学员中心(自服务顶级菜单)
SysMenu studentCenter = new SysMenu();
studentCenter.setMenuId(2240L);
studentCenter.setMenuName("学员中心");
studentCenter.setPath("student");
studentCenter.setComponent("train/me/student/index");
studentCenter.setPerms("train:me:student");
list.add(studentCenter);
// 4. 学员画像
SysMenu portrait = new SysMenu();
portrait.setMenuId(2173L);
portrait.setMenuName("学员画像");
portrait.setPath("portrait");
portrait.setComponent("train/portrait");
portrait.setPerms("train:portrait:query");
list.add(portrait);
// 5. 系统管理
SysMenu sysMgmt = new SysMenu();
sysMgmt.setMenuId(1L);
sysMgmt.setMenuName("系统管理");
sysMgmt.setPath("system");
sysMgmt.setComponent("Layout");
list.add(sysMgmt);
return list;
}
@SuppressWarnings("unchecked")
private List<SysMenu> invokeFilterSelfServiceMenus(List<SysMenu> menus, Long userId) throws Exception
{
Method method = SysLoginController.class.getDeclaredMethod("filterSelfServiceMenus", List.class, Long.class);
method.setAccessible(true);
return (List<SysMenu>) method.invoke(loginController, menus, userId);
}
/**
* 1. 验证 admin 登录时自服务菜单隐藏,且普通管理菜单完整保留
*/
@Test
public void testAdminSelfServiceMenuHidden() throws Exception
{
List<SysMenu> menus = createMockMenuTree();
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentAdminUserId);
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
boolean hasPeopleMgmt = filtered.stream().anyMatch(m -> "人员管理".equals(m.getMenuName()));
boolean hasPortrait = filtered.stream().anyMatch(m -> "学员画像".equals(m.getMenuName()));
boolean hasSystemMgmt = filtered.stream().anyMatch(m -> "系统管理".equals(m.getMenuName()));
assertFalse("admin 教师工作台必须 HIDDEN", hasTeacherWorkbench);
assertFalse("admin 学员中心必须 HIDDEN", hasStudentCenter);
assertTrue("admin 人员管理必须保留", hasPeopleMgmt);
assertTrue("admin 学员画像必须保留", hasPortrait);
assertTrue("admin 系统管理必须保留", hasSystemMgmt);
// 人员管理下属的教师信息与学员信息完好
SysMenu people = filtered.stream().filter(m -> "人员管理".equals(m.getMenuName())).findFirst().orElse(null);
assertNotNull(people);
assertEquals(2, people.getChildren().size());
assertEquals("教师信息", people.getChildren().get(0).getMenuName());
assertEquals("学员信息", people.getChildren().get(1).getMenuName());
}
/**
* 2. 验证 train_admin 登录时自服务菜单隐藏,业务菜单完整保留
*/
@Test
public void testTrainAdminSelfServiceMenuHidden() throws Exception
{
List<SysMenu> menus = createMockMenuTree();
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentTrainAdminUserId);
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
boolean hasPeopleMgmt = filtered.stream().anyMatch(m -> "人员管理".equals(m.getMenuName()));
assertFalse("train_admin 教师工作台必须 HIDDEN", hasTeacherWorkbench);
assertFalse("train_admin 学员中心必须 HIDDEN", hasStudentCenter);
assertTrue("train_admin 人员管理必须保留", hasPeopleMgmt);
}
/**
* 3. 验证具备真实教师身份的用户:教师工作台 VISIBLE,学员中心 HIDDEN
*/
@Test
public void testTeacherSelfServiceMenuVisible() throws Exception
{
List<SysMenu> menus = createMockMenuTree();
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentTeacherUserId);
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
assertTrue("teacher 用户教师工作台必须 VISIBLE", hasTeacherWorkbench);
assertFalse("teacher 用户学员中心必须 HIDDEN", hasStudentCenter);
}
/**
* 4. 验证具备真实学员身份的用户:学员中心 VISIBLE,教师工作台 HIDDEN
*/
@Test
public void testStudentSelfServiceMenuVisible() throws Exception
{
List<SysMenu> menus = createMockMenuTree();
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentStudentUserId);
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
assertFalse("student 用户教师工作台必须 HIDDEN", hasTeacherWorkbench);
assertTrue("student 用户学员中心必须 VISIBLE", hasStudentCenter);
}
/**
* 5. 验证同时具备教师与学员真实身份的用户:分别按真实身份可见
*/
@Test
public void testDualIdentityUserBothVisible() throws Exception
{
List<SysMenu> menus = createMockMenuTree();
List<SysMenu> filtered = invokeFilterSelfServiceMenus(menus, currentDualUserId);
boolean hasTeacherWorkbench = filtered.stream().anyMatch(m -> "教师工作台".equals(m.getMenuName()));
boolean hasStudentCenter = filtered.stream().anyMatch(m -> "学员中心".equals(m.getMenuName()));
assertTrue("双重身份用户教师工作台 VISIBLE", hasTeacherWorkbench);
assertTrue("双重身份用户学员中心 VISIBLE", hasStudentCenter);
}
/**
* 6. 验证非法直接访问保护:即便直接调用接口,无身份档案仍被 403 拦截
*/
@Test
public void testDirectUnauthorizedAccessStillBlocked()
{
// 模拟直接访问教师工作台接口
try
{
mockIdentityService.assertTeacherIdentity();
fail("未绑定教师档案的用户直接访问必须抛出 403 ServiceException");
}
catch (ServiceException e)
{
assertEquals(403, e.getCode().intValue());
assertTrue(e.getMessage().contains("教师档案"));
}
// 模拟直接访问学员中心接口
try
{
mockIdentityService.assertStudentIdentity();
fail("未绑定学员档案的用户直接访问必须抛出 403 ServiceException");
}
catch (ServiceException e)
{
assertEquals(403, e.getCode().intValue());
assertTrue(e.getMessage().contains("学员档案"));
}
}
/**
* 7. 验证前端静态契约:
* - selfServiceRoute.js 存在且实现隔离过滤
* - permission.js 正确引入并执行自服务路由过滤
* - request.js 包含 403 防风暴机制
* - 教师与学员页面具备加载守卫
*/
@Test
public void testFrontendRoleNavigationContracts() throws Exception
{
File utilsFile = new File(PROJECT_ROOT, "build_ui/src/utils/selfServiceRoute.js");
assertTrue("selfServiceRoute.js 必须存在", utilsFile.exists());
String utilsContent = new String(Files.readAllBytes(utilsFile.toPath()), "UTF-8");
assertTrue("必须定义 isSelfServiceRouteAllowed", utilsContent.contains("isSelfServiceRouteAllowed"));
assertTrue("必须限制 train_teacher", utilsContent.contains("train_teacher"));
assertTrue("必须限制 train_student", utilsContent.contains("train_student"));
File permFile = new File(PROJECT_ROOT, "build_ui/src/store/modules/permission.js");
assertTrue("permission.js 必须存在", permFile.exists());
String permContent = new String(Files.readAllBytes(permFile.toPath()), "UTF-8");
assertTrue("permission.js 必须导入 selfServiceRoute", permContent.contains("selfServiceRoute"));
assertTrue("permission.js 必须在 filterAsyncRouter 中检查 roles", permContent.contains("isSelfServiceRouteAllowed"));
File reqFile = new File(PROJECT_ROOT, "build_ui/src/utils/request.js");
assertTrue("request.js 必须存在", reqFile.exists());
String reqContent = new String(Files.readAllBytes(reqFile.toPath()), "UTF-8");
assertTrue("request.js 必须包含 403 防抖", reqContent.contains("lastForbiddenNotificationTime") && reqContent.contains("FORBIDDEN_DEBOUNCE_MS"));
File teacherVue = new File(PROJECT_ROOT, "build_ui/src/views/train/me/teacher/index.vue");
String teacherContent = new String(Files.readAllBytes(teacherVue.toPath()), "UTF-8");
assertTrue("教师页面必须在身份验证成功后再加载排课数据", teacherContent.contains("loadMyPlans") && !teacherContent.contains("initTeacherData() {\n this.loadProfile()\n this.loadMyPlans()"));
File studentVue = new File(PROJECT_ROOT, "build_ui/src/views/train/me/student/index.vue");
String studentContent = new String(Files.readAllBytes(studentVue.toPath()), "UTF-8");
assertTrue("学员页面必须在身份验证成功后再加载Tab数据", studentContent.contains("loadEnrollments") && !studentContent.contains("initStudentData() {\n this.loadProfile()\n this.loadEnrollments()"));
}
}