写JavaWeb教学系统的人,十有八九都会在权限管理这个模块上栽一回。不是功能做不出来,而是做出来之后没法看:改个密码要重启Tomcat,换个角色要改代码,给老师分配个课程权限得登录数据库敲SQL。更尴尬的是,很多人把权限写死在Servlet里,每个请求进来先if判断角色,代码膨胀到连自己都懒得看。
我这次想聊的不是那种“能用就行”的权限模块,而是基于RBAC思路、用纯Servlet+JSP+jQuery这套经典JavaWeb组合实现的一套权限管理模块。这套东西不依赖Spring Security,也不用Shiro,就靠三个核心东西:Filter做拦截、Session做会话状态管理、五张核心表做数据支撑。做完之后能实现动态菜单、URL级权限控制、角色可配置文件、用户注册审批,整体功能覆盖教学系统里最常见的“学生、教师、教务管理员”三类角色场景。
本文会从模型设计、数据库表结构、核心技术实现到实际踩坑,完整拆一遍这套权限模块的设计过程。
1. 项目定位:这套权限模块到底要解决什么问题
1.1 教学系统里权限需求的真实拆解
很多初学者一提到权限管理,第一反应就是“登录之后放行,没登录就跳转登录页”。但真实的教学系统比这个复杂得多。我当时做这个项目的时候,需求是这样的:
- 普通学生:登录后可以查看自己的课程、成绩、提交作业
- 教师:可以发布课程内容、批改作业、录入成绩
- 教务管理员:可以管理所有用户、分配角色、查看全系统报表
- 访客(未登录):只能看到系统首页和公告,其它资源全部拦截
这四类角色如果只是用“是否登录”来判断,完全行不通。教师可以批改成绩,学生绝对不能;教务管理员可以给教师分配课程,但教师自己不能改自己的角色权限。
更麻烦的是,同一个用户可能同时具备多个角色。比如一位既是授课教师又是班主任的老师,他既能进入教学管理模块,又要能访问班级管理模块,这就是典型的“多角色”场景。
1.2 为什么选择RBAC模型而不是简单角色判断
RBAC(Role-Based Access Control,基于角色的访问控制)的核心思路就是:用户不直接关联权限,而是通过角色间接获得权限。这个模型的优势非常明显。
拿上面那个既是教师又是班主任的例子来说,如果直接在User表里加字段role=teacher,班主任功能就废了;加role=monitor,课程功能又废了。用RBAC的思路,只需要给这个用户分配两个角色即可,每个角色各管各的权限。
不过,很多教学项目只是套了个RBAC的壳,实际上还是用最原始的if判断:从Session里取角色名,然后手工判断每个请求是否需要该角色。这样做的后果是权限逻辑散落在各个Servlet里,一改需求就得翻遍所有代码。
我实现这套模块时做了两层设计。第一层是基于Filter的URL拦截,用路径前缀匹配的方式做粗粒度控制,比如/admin/开头的请求必须有管理员角色,/teacher/开头的必须有教师角色。第二层是在Servlet内做细粒度校验,比如某个操作需要“成绩录入”这个具体权限点,而不仅仅是“教师角色”。
这套方案的合理解释是:URL前缀控制解决了大部分问题,细粒度权限点控制覆盖剩余少数场景,两者结合既避免了写几百个Filter,也不会因为权限粒度太粗而漏控制。
2. 数据库表设计:权限模块的地基
2.1 用五张表撑起整套权限体系
我设计的表结构是在经典RBAC方案上加了一张权限表,总共五张核心表,外加几个业务辅助表。先看总体结构关系,再逐个详解。
用户表user、角色表role、权限表permission,这三张是基础实体表。中间两张关联表:user_role维护用户与角色的多对多关系,role_permission维护角色与权限的多对多关系。这张关系网的价值在于,后续加一个全新角色只需要往角色表和关联表里插数据,一行Java代码都不用动。
表结构设计时走了两步思考。第一步,确定主键策略——这里建议不要用自增主键,而是采用雪花算法生成的19位长整型。原因是权限模块的数据表需要被多个系统复用,如果后续做数据迁移、或者在微服务架构下拆分库,自增主键特别是遇到有格式约定的时候非常不灵活。第二步,确定命名规范——所有字段名统一用下划线分隔的小写英文字母,禁用关键字(如rank、level之类不要直接做字段名,会给自己找麻烦)。
2.2 user表与role表的字段细节
用户表user最核心的字段:
CREATE TABLE `user` ( `id` BIGINT NOT NULL COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)', `real_name` VARCHAR(30) DEFAULT NULL COMMENT '真实姓名', `status` TINYINT DEFAULT 1 COMMENT '1启用 0禁用', `is_deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';这里有两个细节值得专门提一下。
第一个是逻辑删除字段is_deleted。我做权限模块时一开始没加这个字段,后来某次做“删除用户”功能时直接把数据物理删了,结果该用户之前的操作日志全部变成了挂空记录,很多历史数据直接丢失。加上逻辑删除之后,删除用户变成update语句,历史记录依然保留,查询时统一过滤is_deleted=0即可。
第二个是密码字段。所有教学系统里都绝不允许明文存储密码,用BCrypt算法对密码做哈希处理。很多同学为了省事用MD5加固定盐加密,这也行,但没有BCrypt可靠,BCrypt自带随机盐,相同密码每次生成的密文都不一样,防止彩虹表还原。
2.3 permission表与role_permission表的关键设计
权限表permission是这套方案中最容易被忽略、也最值得细看的表:
CREATE TABLE `permission` ( `id` BIGINT NOT NULL, `name` VARCHAR(50) NOT NULL COMMENT '权限名称如:成绩录入', `code` VARCHAR(50) NOT NULL COMMENT '权限编码如:score:input', `type` TINYINT DEFAULT 1 COMMENT '1菜单权限 2操作权限', `parent_id` BIGINT DEFAULT 0 COMMENT '父权限ID,0表示根节点', `path` VARCHAR(200) DEFAULT NULL COMMENT '对应的访问路径', `sort_order` INT DEFAULT 0 COMMENT '排序号', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';type字段的设计很巧妙,它将权限分成了“菜单权限”和“操作权限”两类。菜单权限控制的是“用户能看见哪些侧边栏菜单”,操作权限控制的是“用户能执行哪些动作(比如按钮级)”。
权限编码code的命名我采用模块:操作的方式,比如course:add表示新增课程,course:delete表示删除课程,score:input表示录入成绩。这实际上是借鉴了流行的RBAC+细粒度模式的做法,但是用纯JavaWeb的方式也能轻松实现。
role_permission表结构非常简单,只有三个字段,role_id、permission_id、create_time。它的查询逻辑是所有权限验证的核心。
2.4 初始化数据怎么准备
表结构建好后,最崩溃的时刻来了:数据一开始都是空的,系统根本跑不起来。这时候需要准备初始化SQL。
我建议的初始化数据策略是有两层数据必填。
第一层是超管账号,在user表中直接插入一条记录,用户名取名为admin,密码用BCrypt生成一个初始化种子,需要先跑一个Java程序来生成加密串,不能把明文密码直接写进SQL。
第二层是基础角色和基础权限数据。系统初始化时预置三套角色:ROLE_ADMIN(教务管理员)、ROLE_TEACHER(教师)、ROLE_STUDENT(学生),并给每种角色赋予默认权限。这里要注意权限数据的组织逻辑:先创建权限,再关联角色,否则菜单都没得挂。
初始化SQL可以直接写成插入语句,但项目发布时这些是用程序自动执行的,这样才能保证任何环境配置一致。我写了一个DataInitializer类,在Web应用启动时通过监听器触发执行初始化逻辑。
3. 核心技术选型:为什么这个项目不用Spring Boot
3.1 “落后”的Servlet/JSP技术栈反而更适合教学定位
这个决定可能会被很多人质疑。现在随便去招聘网站看一圈,JavaWeb岗位几乎都要求Spring Boot,为什么我还要用Servlet+JSP来搭权限模块?
这个项目的目标和生产架构不同,教学系统要的不是效率,而是原理可读性和学习阶梯。Spring Boot的自动配置把请求流程、拦截器注册、权限注解都封装在框架内部,学生知道结果是权限生效了,但不知道它为什么生效。而用Servlet写Filter、用JSP做页面跳转、手动管理Session,每一层流转都是用代码写死的,学生能完整看到请求从进来到返回的整个链路。
还有一层现实原因:很多高校的课程考核甚至毕业设计验收,重点看的不是框架多新,而是核心逻辑是否清晰、代码是否结构化。权限模块在整个系统里的地位就是一个适合用来展示架构的模块,它能很好地展现数据库设计能力、过滤器编写能力、会话状态设计能力和可维护代码能力。
3.2 整个项目的包结构和运行环境
项目采用标准的Maven工程结构,按功能模块分包,而不是按技术层分包。这一点可能和很多教科书上讲的传统分层开发有些区别。
传统结构是按controller/service/dao拆,但权限模块的功能边界非常清晰,按业务功能划分包结构会更直观,也便于后续维护。当功能越来越大时,我可以直接复制一个包并修改routing,代码调整的成本非常低。
核心包结构如下:
com.school.authentication ├── entity # 实体类:User、Role、Permission、UserRole、RolePermission ├── dao # 数据访问接口及实现(原生JDBC封装) ├── service # 业务逻辑层:用户认证服务、角色权限服务 ├── filter # 权限过滤器:AuthFilter核心类 ├── servlet # 表现层处理:LoginServlet、UserServlet、RoleServlet ├── util # 工具类:DBUtil、MD5Util、JwtUtil(用于部分场景token生成) └── listener # 启动监听器:初始化数据运行环境就三件套:JDK 1.8、Tomcat 8.5、MySQL 5.7,JSP页面配合JSTL和jQuery。这套环境兼容性极强,在绝大多数实验室和老机器上跑起来都能稳定运行。
3.3 数据库连接为什么要自己封装而不是用连接池
这个问题我纠结了很久,最终给出了一个务实的答案:如果纯粹是权限模块的教学演示项目,直接用工具类封装JDBC可行,但不够好。普通JDBC每执行一次SQL就要建立一次连接,频繁调用时开销巨大。因此我选择采用轻量级的数据库连接池方案,用Apache Commons DBCP或c3p0,这样既能保证应用性能,又不会引入太重量的框架依赖。
数据库连接配置通过db.properties属性文件管理:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/school_system?useUnicode=true&characterEncoding=utf8 jdbc.username=root jdbc.password=123456 jdbc.initialSize=5 jdbc.maxActive=20用连接池之后,代码里获取连接变成一行调用,然后执行SQL的业务逻辑基本不需要关心连接生命周期。这样既避免了手写JDBC连接开销大的问题,又比直接引入MyBatis更贴近理解SQL执行原始过程的初衷。
4. Filter权限拦截:最核心的环节
4.1 从一次请求的完整旅程理解Filter的作用
权限模块的灵魂不在Servlet里,而是Filter和Session的组合逻辑。
Tomcat处理请求是走过滤器链的的:请求到达后先被Servlet容器接管,依次经过所有匹配的Filter,最后才进入目标Servlet。在Filter中可以对请求做出拦截、修改、放行三种处理。
在我们的权限场景中,请求经过三个关键阶段:
第一阶段是全局过滤器判断请求是否需要登录,比如登录接口、静态资源、首页公开资源直接放行。
第二阶段是会话状态验证,也就是去Session中找当前登录用户的信息,找不到就重定向到登录页。
第三阶段是权限校验,根据当前用户拥有的角色去和本次请求URL所要求的权限做匹配。匹配通过进入Servlet,匹配失败则跳转到403页面或返回JSON提示。
4.2 AuthFilter核心代码详解
直接上一版亲手调过的核心实现,所有注释尽量写清楚为什么这么处理。
public class AuthFilter implements Filter { private static final Set<String> PUBLIC_PATHS = new HashSet<>(Arrays.asList( "/login", "/logout", "/register", "/public/", "/assets/", "/static/" )); private static final Map<String, String> ROLE_PATH_PREFIX = new HashMap<>(); static { ROLE_PATH_PREFIX.put("/admin/", "ROLE_ADMIN"); ROLE_PATH_PREFIX.put("/teacher/", "ROLE_TEACHER"); ROLE_PATH_PREFIX.put("/student/", "ROLE_STUDENT"); } @Override public void init(FilterConfig filterConfig) throws ServletException { // 初始化时可以读取web.xml中的初始化参数,也可以从配置中心读取 } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); String ctxPath = req.getContextPath(); String path = uri.substring(ctxPath.length()); // 公开资源直接放行 for (String publicPath : PUBLIC_PATHS) { if (path.startsWith(publicPath)) { chain.doFilter(request, response); return; } } // 检查Session中是否登录 Object loginUser = req.getSession().getAttribute("loginUser"); if (loginUser == null) { // 判断是普通页面请求还是Ajax请求,分别处理 if ("XMLHttpRequest".equals(req.getHeader("X-Requested-With"))) { resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"未登录或会话已过期\"}"); } else { resp.sendRedirect(ctxPath + "/login.jsp"); } return; } // 角色路径校验 for (Map.Entry<String, String> entry : ROLE_PATH_PREFIX.entrySet()) { if (path.startsWith(entry.getKey())) { User user = (User) loginUser; boolean hasRole = checkUserRole(user.getId(), entry.getValue()); if (!hasRole) { resp.setStatus(403); resp.getWriter().write("{\"code\":403,\"msg\":\"无权限访问\"}"); return; } break; } } // 权限点校验 Permission required = findRequiredPermission(path); if (required != null) { User user = (User) loginUser; if (!checkUserPermission(user.getId(), required.getCode())) { resp.setStatus(403); resp.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}"); return; } } chain.doFilter(request, response); } private boolean checkUserRole(Long userId, String roleCode) { // 查询 user_role + role 表 UserRoleDao roleDao = new UserRoleDao(); return roleDao.isUserHasRole(userId, roleCode); } private boolean checkUserPermission(Long userId, String permissionCode) { // 通过 user_role -> role_permission -> permission 三表关联查询 PermissionDao permissionDao = new PermissionDao(); return permissionDao.isUserHasPermission(userId, permissionCode); } @Override public void destroy() { // 释放资源 } }5. 登录认证与Session会话管理
5.1 登录接口哪几个环节最容易出问题
登录接口看似简单,实际上暗坑极多。我在这个模块里处理登录逻辑时踩到过以下两个高频问题。
第一个是验证码问题。很多教学系统登录页面完全不带验证码,导致被脚本暴力破解。我在这里采用了简单方案,在登录页面使用一个简易的CaptchaServlet,每次刷新生成四位数字验证码存到Session中,提交时比对忽略大小写。如果验证码校验失败,直接返回登录失败,不会继续走用户比对流程。
第二个是登录状态存储限制。很多纯JavaWeb项目喜欢直接把整个User对象塞进Session,然后到处取出使用。这里有一个很大的隐患:如果用户修改了个人资料(比如改头像、改真实姓名),Session里的User对象不会自动同步,下次取值时还是旧数据。我的处理是在修改资料之后主动更新Session中的对象,用一个refreshLoginUser方法将最新用户信息重新覆盖到Session里。
5.2 登录流程实现与安全设计
我的LoginServlet执行流程如下:
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); String captcha = req.getParameter("captcha"); // 1. 验证码校验 String sessionCaptcha = (String) req.getSession().getAttribute("captcha"); if (sessionCaptcha == null || !sessionCaptcha.equalsIgnoreCase(captcha)) { resp.getWriter().write("{\"code\":400,\"msg\":\"验证码错误\"}"); return; } // 2. 用户名密码校验 User user = userDao.findByUsername(username); if (user == null) { resp.getWriter().write("{\"code\":400,\\"msg\":\"用户名或密码错误\"}"); return; } // 3. 校验密码 if (!BCrypt.checkpw(password, user.getPassword())) { resp.getWriter().write("{\"code\":400,\"msg\":\"用户名或密码错误\"}"); return; } // 4. 校验状态 if (user.getStatus() == 0) { resp.getWriter().write("{\"code\":403,\"msg\":\"账号被禁用,请联系管理员\"}"); return; } // 5. 写入Session req.getSession().setAttribute("loginUser", user); req.getSession().setAttribute("loginTime", new Date()); resp.getWriter().write("{\"code\":200,\"msg\":\"登录成功\"}"); }注意上面的密码验证流程用的是BCrypt.checkpw方法,而不是简单的字符串equals。因为数据库存的是BCrypt哈希串,实际验证时是通过哈希算法重新计算并比较的,不会有明文密码出现在查询和日志中。
5.3 会话固定攻击防护与自动超时
在教学系统项目中很容易忽略的一个安全点是Session固定攻击防护。所谓会话固定攻击,就是攻击者先拿到一个Session ID,伪造链接发给用户,用户登录成功之后,Session ID不变,攻击者因此获得与用户等价的会话。
防护手段很简单:用户登录成功之后,立即调用req.getSession().invalidate()使旧会话失效,然后重新创建新会话并存放登录信息。这步代码虽然只有一行,但很多人根本不会想到。
// 登录成功后重建Session,防止会话固定攻击 HttpSession oldSession = req.getSession(false); if (oldSession != null) { oldSession.invalidate(); } HttpSession newSession = req.getSession(true); newSession.setAttribute("loginUser", user);关于会话超时,有两种配置方式。第一种是web.xml里设置:
<session-config> <session-timeout>60</session-timeout> </session-config>这里的单位是分钟,意味着用户连续60分钟没有任何请求操作,Session就自动失效。第二种是通过代码按需设置,比如管理后台需要更长超时,用setMaxInactiveInterval控制。
我在AuthFilter里增加了一个“滑动续期”动作:每次请求通过过滤时,如果当前Session离过期时间只剩十分钟,就调用setMaxInactiveInterval重新计时。这样用户一直在操作就一直不会掉线,避免写着写着突然弹回登录页的尴尬。
6. 动态菜单与按钮级权限控制
6.1 不同角色登录后看到的菜单为什么不一样
菜单权限是权限模块的一个可视化强输出维度。学生登录后应当看到“我的课程、我的成绩、作业提交”,教师登录后应当看到“课程管理、学生成绩、作业批改”,教务管理员则应该看到全量菜单。
实现思路并不复杂:登录成功后在Session里存放当前用户所拥有的菜单权限列表,JSP页面上使用JSTL的c:forEach动态渲染菜单,不同角色的菜单列表由后端SQL根据角色和权限表实时查询。
<c:forEach items="${sessionScope.menuList}" var="menu"> <li class="nav-item"> <a href="${pageContext.request.contextPath}${menu.path}"> <span>${menu.name}</span> </a> </li> </c:forEach>动态菜单的核心是权限表里type=1的数据。每次登录成功后,查询当前用户的所有权限中type为1的记录,按照sort_order排序返回。这样角色和菜单改动不需要修改页面,只需要调整数据库关联即可。
6.2 按钮级权限控制怎么实现
菜单级权限解决的是“看得到看不到”,按钮级权限解决的是“点得了点不了”。
很多系统的做法是前端隐藏按钮,这种做法实际效果很差,因为按钮藏起来用户不知道有这个功能,而且在浏览器里直接改代码就能让按钮显示出来。更稳妥的做法是前端控制显示,后端再做一次真正的权限校验——也就是权限校验要以服务端为准,前端控制只是体验优化。
我在JSP中直接可以判断:
<c:if test="${sessionScope.permissionSet.contains('course:add')}"> <button onclick="openAddCourseModal()">新增课程</button> </c:if>这里要特别强调:这个标签只控制显示,后端在CourseServlet的doPost里必须再次校验当前用户是否真的拥有course:add权限码,否则一个懂点前端的人拿POST一打直接就穿过了所有按钮限制。
6.3 从数据库到内存:降低权限查询压力
权限验证如果每请求一次就查询一次数据库,流量一大数据库扛不住。这里我引入了一个很小的优化:用本地缓存存储用户权限码集合。
具体做法是:在AuthFilter初始化时创建静态Map,登录成功后把用户的权限码Set存进去,key是userId,value是Set<String>。后续权限点校验直接在缓存中取,如果缓存没有(说明个人权限数据有变化)再去数据库查询。
权限变更时,比如管理员给某个用户添加了角色,直接在修改逻辑中调用permissionCache.remove(userId)清除对应缓存。这个策略非常简单,但能极大降低权限验证带来的数据库压力,而且不会出现复杂的分布式缓存问题。
7. 用户管理与角色分配:后台的完整闭环
7.1 用户管理页面的核心功能
用户管理页面是权限模块的业务落地点。只做权限校验却不提供管理入口,这套模块就是残缺的。
我实现的功能点包括:用户列表查询(分页+模糊查询)、新增用户、编辑用户(重置密码、修改状态)、删除用户(逻辑删除)、角色分配。
用户列表的查询实现中有一个值得分享的点:分页查询。很多初学者写分页是用limit实现,这个思路没问题,但还需要一个总记录数count查询,否则页面展示不了总页数。核心代码如下:
public PageResult<User> selectUserPage(int pageNum, int pageSize, String keyword) { // 构建count查询 String countSql = "SELECT COUNT(*) FROM user WHERE is_deleted=0"; // 构建分页查询 String querySql = "SELECT * FROM user WHERE is_deleted=0 LIMIT ?, ?"; // 参数处理注意PreparedStatement的索引从1开始 }7.2 角色分配功能中事务的真实需求
给用户分配角色是整个后台最需要加事务的功能,没有之一。因为它会涉及多次删除和多次插入:
@Transaction public void assignRoles(Long userId, Long[] roleIds) { // 1. 删除该用户原有的所有角色关联 userRoleDao.deleteByUserId(userId); // 2. 批量插入新的角色关联 for (Long roleId : roleIds) { userRoleDao.insert(new UserRole(userId, roleId)); } }如果不用事务,当第1步成功了,第2步插入因为数据库异常中断时,用户的旧角色已经被删了,但新角色没插上,用户就变成了一个没有任何角色的孤儿状态,后患无穷。解决的办法也不复杂,在Service方法上加事务控制,或者手动在DAO层操作中控制提交与回滚。
对于纯JDBC的写法,需要自己管理Connection和事务状态:
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 执行删除和插入 conn.commit(); } catch (SQLException e) { if (conn != null) { conn.rollback(); } throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } }这种方式确实比较啰嗦,但它把事务原理展示得很清楚,任何用过后端框架的人看到这段代码都能秒懂。
7.3 组件复用:用户导入导出
后台系统还有一个常见功能:批量导入用户。这需要处理Excel文件,考虑到不要引入过多的框架,我采用了Apache POI的HSSFWorkbook实现简化版用户批量导入逻辑。
导入时核心校验点包括:用户名唯一性校验需要先查数据库;密码列如果是空的,给一个默认密码123456;用户状态默认启用;按行读取Excel时要注意空行跳过。
try (Workbook workbook = WorkbookFactory.create(InputStream)) { Sheet sheet = workbook.getSheetAt(0); for (int i = 1; i <= sheet.getLastRowNum(); i++) { Row row = sheet.getRow(i); if (row == null) continue; String username = row.getCell(0).getStringCellValue(); String realName = row.getCell(1).getStringCellValue(); // 校验、加密、插入 } }这个功能在期末选课期间特别实用,老师拿着名单就能一次性导入好全班学生的账号,省去了一个个人工创建的时间。
8. 常见问题与排查技巧实录
8.1 Filter不生效但配置没问题,怎么回事
这是纯JavaWeb项目里极其常见的问题。Filter配置正确、类也写了,但请求就是不走Filter。
大概率是web.xml中配置了Filter后,注解方式重复声明导致的。如果Filter类上同时标注了@WebFilter注解,并且在web.xml中也定义了filter-mapping,就会产生两个Filter实例,Tomcat无法确定哪一个应该被加载,甚至会出现请求反复进入同一个Filter造成的死循环。
我的实践是:统一使用web.xml配置方式,不用注解。原因很简单,权限Filter的url-pattern和初始化参数用xml配置一目了然,排查问题时打开web.xml直接能看到所有过滤器规则,整个清晰度完全不一样。
还有一个隐藏原因:Filter的url-pattern匹配规则没搞清楚。url-pattern写的/**和 / 的区别非常大,前者匹配所有请求包括静态资源,后者只匹配根路径。想要正确拦截所有业务请求,必须写/*,如果写错了,Filter自然不生效。
8.2 登录成功后跳转循环——Session访问时机错误
有时候用户输对账号密码之后,页面不停地跳转,始终进不了首页。原因是登录成功后马上将请求转发到了首页,但随后AuthFilter对首页路径也做了Session校验,然而此时Session写入没有完成或者Session对象实际已经过期了。
我遇到过的具体场景是:在登录Servlet中把User对象先setAttribute到request域中,然后request.getRequestDispatcher("index.jsp")转发,这种做法根本不会写入Session,AuthFilter必然拿不到登录用户。
正确做法:先把用户信息保存到Session中,再进行重定向或转发,而且重定向比转发更合适,因为重定向会发起新的请求,重新经过过滤器链,权限过滤结果才能被验证。
调试此类问题最好的方法:在登录Servlet和AuthFilter中分别打日志,输出Session ID和当前是否包含loginUser的检查结果,这样一眼就能看出问题在哪一环。
8.3 Ajax请求被拦截后跳转到登录页的bug
这个坑比较隐蔽。前端用jQuery的$.post提交表单,后台权限校验失败时如果是页面重定向响应,前端拿到的是整个JSP页面的HTML,而不是期望的JSON错误信息。
我遇到学生做项目时出现这种情况,前端接口功能直接“整个页面崩了”。解决办法也很直接:在Filter中判断请求是否为Ajax,需要获取X-Requested-With请求头,jQuery默认会携带这个头,值为XMLHttpRequest。
在此基础上对Ajax请求返回JSON格式的401或403状态码,对普通浏览器请求返回重定向。前端再统一拦截响应做Login跳转或提示。核心判断代码参考前文AuthFilter中的分支逻辑。
为了让前后端更好地配合,我设计了一套统一的JSON返回结构,消息码定义是:0表示成功,1表示业务失败,401表示未登录,403表示无权限,500表示服务器错误。前端在ajax的complete回调里统一判读,根据状态码做不同的跳转或提示。
8.4 权限数据修改后不生效——缓存未清除
权限模块做久了就会遇到“哎呀我明明给这个用户加了权限,他刷新好几遍还是进不去”的问题。
如果使用了第6节提到的本地权限缓存策略,问题大概率出在权限变更后没有主动清除缓存。用户重新登录并不会自动清缓存,因为缓存key是userId允许复用,只有重新登录触发权限列表加载才会刷新。
我在这块做了一个很笨但很有效的处理方式,在修改用户角色的代码里主动删掉该用户缓存,修改角色权限的代码里主动删掉所有拥有该角色的用户缓存。虽然有一定的粗暴度,但因为教学系统用户量级不大,这种处理完全够用。如果后续量级大了,可以引入定时刷新或缓存版本号机制,那是后话了。
8.5 SQL注入与XSS防护
权限管理模块涉及大量查询和增删改操作,很容易被各种方式攻击。特别是用户输入的内容(如关键词搜索、用户名、备注信息等)直接拼进SQL语句,会造成灾难性后果。
这里从操作层面养成两个习惯。第一,所有SQL统一使用PreparedStatement预编译,从来不需要去手动拼接字符串构造SQL。参数用?替代,再通过setXxx方法设置,MySQL驱动会正确转义单引号和特殊字符,无法实现最经典的注入。第二,JSP页面输出用户可控内容时使用JSTL的c:out标签而不是直接用EL表达式裸输出,c:out默认会转义HTML标签,能够防御XSS。
<!-- 安全输出用户输入 --> <c:out value="${user.remark}" />除了这两个习惯,建议在JSP模板中开启isELIgnored属性为false,防止异常情况下EL表达式被当作文本输出。我的做法是在Controller层对输入内容做一层白名单校验,比如真实姓名只允许中英文和空格,备注信息长度限制200以内,超长内容直接丢弃。
9. 这套权限模块还能怎么扩展
写到这里,权限模块的核心实现基本完整。但任何一个合格的后端开发都应该有“面向未来”的设计意识。我在做这套模块时,额外预留了几个扩展点,这里可以一并分享一下。
第一个扩展点是数据权限。目前做的都是功能权限(一个用户能否访问一个路径),但真实教学系统里还需要考虑数据权限——比如教师只能看到自己班级的成绩,教务管理员可以看全年级的成绩。数据权限一般不做进表和URL里,而是在查询层统一加上数据范围过滤条件。我预留了一个DataScope注解的思路,在Service层用一个统一的工具类做数据范围拼接。
第二个扩展点是接口鉴权。如果这个教学系统后续要提供移动端或者微信小程序,那基于Session的会话管理模式就不再适用。预先设计了一个基于Token的登录模式,登录成功后返回一个随机Token(可以用JWT,也可以用简单的UUID存内存),后续接口携带Token请求,安全校验通过后解析出当前用户。这个模式和Session不能同时存在,但数据结构(User、Role、Permission)完全通用。
第三个扩展点是审计日志。权限模块涉及用户授权、角色变更等敏感操作,建议在角色分配和用户管理操作中埋点记录操作日志:谁在什么时间对哪个用户做了哪种操作。我的实现是在Servlet中统一用OperationLogUtil.writeLog方法,在方法调用前后记录参数和结果,这样一个简单的操作日志功能就完成了。
这些扩展点不是必须的,但代码设计时把Service层和DAO层分离,包结构独立,加新功能时完全不破坏原有逻辑。
最后分享几个我个人的实操体会:权限管理模块是JavaWeb项目里为数不多的“重流程”模块,它涉及的表关系、请求链路校验、前后端交互都比单表CRUD复杂得多。在做这类模块时,不要急着写代码,先把流程图画透,把数据流想透,再动手反而是最快的方式。调权限问题的时候,请一定相信日志,AuthFilter把请求路径、Session状态、权限校验结果打到log里,大多数表面上神秘的bug都能在日志里找到答案。如果连接数据库执行权限查询时速度不理想,可以先用MySQL的EXPLAIN看一遍执行计划,之后再决定要不要在关联表上补索引。
希望这篇文章能给正在做JavaWeb教学系统、或者准备自己动手彻底理解RBAC权限管理的朋友带来一点帮助。权限模块是一个越挖越深的领域,文章里写的都是我能确定的实践,如果你在实现中遇到不一样的问题,欢迎一起交流。