修复权限:隔离教师工作台与学员中心菜单自服务可见性
This commit is contained in:
1 parent
2b1fcdb260
commit
ecfd38be17
15 files changed
+929
-17
No files matched your search
+94
@@ -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)
|
||||
|
||||
+354
@@ -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()"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user