☰
多角色业务系统权限设计与安全优化:RBAC、JWT与防越权实战
2026/9/26 5:09:25 网站建设 项目流程

我接手的这个企业招聘系统,最初看需求并不复杂:发布职位、投递简历、安排面试、录入评价,标准的Spring Boot CRUD项目。但需求方跑通一轮完整流程后,连问了几个问题:面试官能不能只看到分配给自己的候选人?HR下载简历能不能留操作日志?普通员工账号把面试评分改掉了怎么追溯?

这几个问题全部指向同一个核心:权限管理。招聘系统表面上是简历流转工具,骨子里却是一个多角色、多数据域、多敏感等级的业务系统。用户角色决定了菜单和按钮的可见性,数据流则精确到某条候选人记录谁能看、谁能改。再叠加SQL注入、弱口令、越权访问这些攻击面,系统的安全性完全取决于你有没有把权限隔离和接口防护做到位。

这篇文章不打算泛泛而谈,而是把项目里真正落地、实测有效的一套权限管理机制与安全优化方案整理出来,包含RBAC权限模型设计、五张核心表、Spring Security与JWT集成、防爆破与防越权等完整思路,并附上关键源码片段。技术栈以Spring Boot + MyBatis-Plus + MySQL + Redis为主,招聘系统能用,换成OA、教务管理、简单电商后台也能直接套。适合正在做Java课程设计案例源码、毕业设计,或者刚接触企业级权限设计的朋友参考。

1. 需求拆解:招聘系统里的权限到底在管什么

1.1 四种角色与三个权限颗粒度:一个都不能少

招聘系统至少要划分四种角色:求职者、HR、面试官、系统管理员。求职者投递简历、查看进度、维护个人信息;HR负责发布岗位、筛选简历、安排面试、发送offer;面试官只能看分配给自己的候选人与面试日程,填写评价和打分;管理员负责账号开通、角色分配、日志审计。

很多课程设计做到“求职者/HR/管理员”三张角色就草草收场,把面试官合并进HR,结果答辩时老师一问“面试官凭什么都换成你,为什么不能只看到自己负责的人”,立刻就露馅。正确的做法是围绕真实业务画角色矩阵,每个角色对应一组操作集合,再落到权限点。

权限管理拆开来看是三个颗粒度:菜单权限、按钮权限、数据权限。菜单权限决定登录后左侧导航有哪些模块;按钮权限决定同一个页面上哪些操作能点击,比如HR能“下载简历”,面试官只能“预览摘要”;数据权限最容易被忽略——面试官只能看到分配给自己的候选人记录,HR能看到全部候选人,管理员原则上不能直接翻看业务数据。

这三个颗粒度在实际代码里对应三层校验:前端路由和按钮显隐、后端接口的权限注解、Service层的数据归属判断。前端隐藏按钮只是用户体验,安全边界必须由后端守住。

1.2 敏感数据的安全边界:把数据分级才能定权限

权限设计的第一步不是建表,而是先给数据分级。招聘系统里的数据可以分成三个等级:公开数据是岗位JD、公司介绍,登录可见;半公开数据是候选人脱敏后的简历摘要,比如姓名、学历、工作年限,面试官可以看;高敏数据是手机号、邮箱、身份证号、简历附件、面试评价,访问必须严格限制。

明确分级之后,每个角色能接触哪个等级就一目了然:面试官可以看半公开摘要,但不能看手机号和简历附件;HR可以看全部候选人详情和高敏字段,但所有下载行为要留操作日志;系统管理员只维护账号和权限配置,连业务数据都不要开放查询接口。这样设计出来的权限边界才是清晰的,而不是“谁登录了都能查候选人的手机号”。

顺便说一句,很多开发者在简历接口上只做了登录校验,没做角色区分,结果所有登录用户都能调取候选人手机号。这种漏洞在招聘系统里属于致命的,后面第三部分会专门说怎么堵住。

2. RBAC权限模型设计:让权限从写死变成可配置

2.1 RBAC三层映射:用户、角色、权限为什么要拆开

网上关于RBAC权限管理设计的教程很多,核心思想就一句话:用户不直接关联权限,中间隔一个角色。用门禁卡来类比,员工是用户,门禁卡是角色,能进哪些楼层对应权限;分配门禁卡时不需要关心楼层的每一个门锁,只需要决定把哪张卡发给谁。

在招聘系统里,如果用代码写死条件判断,比如if ("HR".equals(role))来控制所有接口,新增一个“实习HR”角色时就要满项目改代码,维护成本极高。RBAC把权限判断从逻辑判断变成数据比对:角色对应的权限存在数据库里,系统运行时从数据库加载权限集合,判断当前用户是否拥有某个权限码。新增角色、调整权限分配都只是数据操作,不改代码、不重新部署。

这在多人协作的项目里尤其重要。我自己见过太多“毕业设计级”的项目,权限判断散落在几十个Controller方法里,if-else嵌套三层,后来换人接手完全不敢动。RBAC的意义不是炫技,而是让权限体系具备可扩展性。

2.2 五张核心表与初始化数据:字段都帮你定好了

落地RBAC需要五张核心表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。用户表存基础账号信息,密码字段长度直接给128位,因为BCrypt哈希输出固定60个字符,32位是MD5时代的习惯。角色表里建议加一个data_scope字段,标记这个角色的数据范围是“全部”“本部门”还是“仅本人”,虽然实现数据权限不只是这个字段,但它能帮你把意图记录在配置里。

权限表最常见的设计是菜单按钮树:parent_id做父子层级,permission_code用冒号分隔的字符串,比如resume:download、interview:evaluate、job:publish。这类编码风格和Spring Security的hasAuthority天然契合,也方便前端用字符串匹配来控制按钮显隐。类型字段type区分菜单(1)和按钮(2),按钮权限挂在某个菜单下面,形成完整的权限树。

初始化数据时要注意,千万不要只给角色表插几行干巴巴的记录,权限表至少要预置岗位管理、简历管理、面试管理、用户管理四个模块的菜单和按钮权限码,再给管理员角色配上全部权限。否则后续测试时管理员连自己的页面都进不去,还要去数据库里手补数据。

2.3 一次接口请求的鉴权全程:从登录到放行

把RBAC落地到代码里,一次接口请求的鉴权链路是固定的五步。第一步登录,账号密码加验证码校验通过后,生成JWT返回前端,Token里只放用户ID和角色编码列表,不要放密码、身份证号这类敏感信息。第二步前端每次请求在Header带Authorization: Bearer xxxx。

第三步后端的JWT过滤器解析Token,验签、查过期时间,然后把用户信息封装成LoginUser对象塞进Spring Security的上下文。第四步请求进入Controller之前,AOP切面读取方法上的权限注解,比如@RequirePermission("resume:download"),比对当前用户是否持有这个权限码,没有就抛403异常。第五步如果接口还涉及数据级权限,Controller层放行后,Service层还要做归属校验,比如查询候选人详情前判断该候选人是否分配给了当前面试官。

这套链路里最容易出错的是第四步和第五步的职责划分。有些人喜欢把所有校验都写在Controller里,方法一多代码全是重复逻辑;有些人则只做接口注解校验,忘了Service层的数据归属,水平越权就出现了。正确的做法是:注解校验管垂直权限,Service层管水平权限,两层缺一不可。

3. 安全优化:最容易出事也最容易被忽视的五个入口

3.1 SQL注入:一个${}就可能让你整个库被拖走

招聘系统的搜索场景非常多:候选人搜索、岗位列表分页、筛选简历,这些场景一旦处理不好就会成为SQL注入的口子。MyBatis的参数绑定需要分清#{}和${}:#{}走预编译,传进去的值只当字符串处理,没有注入风险;${}是直接拼接SQL片段,一旦内容来自前端参数就有被注入的风险。

最典型的错误写法是排序字段直接用前端传参,比如orderBy = "id desc",然后写进order by ${orderBy}。攻击者把参数改成id desc; drop table sys_user; --,运气好一点整个表就没了。我在实际开发中处理这类需求只有一个原则:所有${}的位置必须做白名单校验,比如定义一个允许排序的字段枚举,前端只能传枚举里的值,其他一律拒绝。

另一个容易被忽视的点是模糊搜索。比如like '%${keyword}%'这种写法,看起来没什么问题,实际上keyword里含一个单引号就可能让SQL报错甚至绕过条件。用concat('%', #{keyword}, '%')就能安全实现模糊查询。写完XML后养成习惯,全局搜一遍项目里有没有${,逐个确认每个位置是不是真的需要动态SQL。

3.2 密码存储与Token:BCrypt加盐与JWT过期策略

密码存储的底线是绝对不能用MD5。MD5属于快速哈希,一张彩虹表破解无盐MD5也就是分钟级的事;即使加了固定盐,一旦盐值泄露,攻击者可以针对性构建彩虹表。正确做法是BCrypt,它自带随机盐,而且哈希计算刻意设计得很慢,通常在100毫秒级别,攻击者跑字典的成本会大幅上升。

Spring Security自带的BCryptPasswordEncoder可以直接用,加密就是encode(plainPassword),校验就是matches(rawPassword, encodedPassword)。这里有个细节:BCrypt生成的字符串本身就包含盐值,所以不需要单独建一列存盐,同一密码每次加密结果都不同是正常现象,不要以为代码写错了。

会话安全方面,JWT一定要设置合理的过期时间。招聘系统建议access token设30分钟到2小时,refresh token设7天,避免Token被盗后长时间有效。Token里不要放敏感字段,因为JWT的payload只是Base64编码,等于明文。退出登录时必须把旧Token加入Redis黑名单,否则“退出”只是前端删掉Token,服务端依然认它。分布式部署时JWT的无状态优势很明显,但也需要配合Redis保存用户状态,一旦账号被禁用、角色被变更,旧Token要立即失效。

3.3 水平越权与垂直越权:简历被陌生人翻走的两种姿势

越权攻击分两种。水平越权是同级用户之间的越权,比如候选人A用自己登录后,把请求里的ID改成候选人B的ID,直接调用/api/resume/detail?id=2拿到B的手机号和简历。垂直越权是低权限用户访问高权限接口,比如求职者直接curl调用POST /api/job/publish尝试发布岗位,后端只要没有做角色判断就会放行。

很多课程设计项目只做了前端按钮隐藏,觉得求职者看不到发布岗位的按钮就安全了,这是完全错误的认识。攻击者根本不需要看到按钮,直接抓包改请求就能绕过前端。防垂直越权的办法是接口层加权限注解,每个写操作接口都校验权限码,没有对应权限码的角色直接拒绝;防水平越权的办法是Service层做数据归属校验,查询某条数据前先确认“这条数据是不是属于当前登录用户”或者“当前用户是否被分配了这条数据”。

我在项目里还加了一条额外的保险:自定义一个越权审计切面,凡是返回403的越权请求都记录操作日志,包括请求路径、参数、登录用户ID、IP地址。配合日志平台做告警,能及时发现有人在批量遍历ID,这在招聘系统里往往意味着正在爬取候选人数据。

3.4 登录防爆破:验证码、限流、账号锁定三级联动

登录接口是所有系统最容易被打的入口,脚本攻击可以同时控制几千个IP对着登录接口跑字典。单靠图形验证码不够,因为验证码本身也能被OCR识别。需要三级联动:验证码防自动化脚本、账号级限流防撞库、IP级限流防分布式爆破。

账号级限流的逻辑是:Redis里用login:fail:username做计数器,密码错误一次INCR一次,同时设置过期时间。连续失败5次后,该账号锁定10分钟,锁定期间即使密码正确也拒绝登录。IP级限流用同一个思路,login:fail:ip:1.2.3.4作为key,同一IP 5分钟内超过30次登录尝试,直接封禁1小时。

实现时有一个很容易踩的坑:计数器的过期时间和自增必须放在同一操作里处理。如果先INCR,再EXPIRE,中间出现并发请求可能造成计数不准确。正常做法是第一次INCR后检查返回值,等于1时马上设置过期时间;后续的INCR不会刷新过期时间,到点自动清零。另外,登录失败提示尽量统一为“用户名或密码错误”,不要明确告诉用户“账号已锁定”或“用户名不存在”,否则等于帮攻击者探测有效账号。

3.5 文件上传与XSS:容易被忽略的两个隐形入口

简历上传是招聘系统绕不开的功能,也是文件上传漏洞的重灾区。只校验扩展名远远不够,攻击者可以把一个WebShell改名为resume.pdf上传,只要服务器没有正确解析文件类型就能执行。至少要校验三层:扩展名白名单、文件头魔数、文件大小限制。PDF文件头部必然有%PDF-字样,Office文件头部是特定的ZIP标志或OLE复合文档标识,用代码读前几个字节就能判断真实的文件类型。

文件落到磁盘时用UUID重命名,不要保留张三_简历.pdf这种原始文件名,避免文件名注入路径穿越问题。文件存储目录必须放在Web应用的不可执行目录,或者直接走OSS/MinIO这类独立文件服务,不要把文件丢进Tomcat的webapps目录下。

XSS方面,面试评价功能一般会用富文本编辑器,这就有问题了。攻击者在评价里嵌入一段<script>代码,如果后端原样存储、前端原样渲染,就形成了存储型XSS,其他HR打开评价页面就会中招。解决办法是后端入库前用白名单过滤,只允许p、strong、em、ul、li、a这类安全标签,剥掉onclick、onerror、script等危险属性和标签。Java生态里Jsoup的clean方法就能做这件事,记得配置白名单而不是黑名单,黑名单永远有漏网之鱼。

4. 核心源码实现:关键代码逐段拆解可以直接抄

4.1 数据库初始化脚本:先把表和权限数据建好

下面这段SQL把RBAC五张核心表建成最小可用版。sys_user的password字段用128位长度容纳BCrypt,sys_permission的permission_code用冒号分段,方便和Spring Security风格对齐。

CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL COMMENT '登录名', `password` varchar(128) NOT NULL COMMENT 'BCrypt哈希', `real_name` varchar(64) DEFAULT NULL COMMENT '姓名', `mobile` varchar(20) DEFAULT NULL COMMENT '手机号', `status` tinyint(4) DEFAULT 1 COMMENT '1启用 0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `sys_role` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `role_code` varchar(64) NOT NULL COMMENT '角色编码', `role_name` varchar(64) NOT NULL COMMENT '角色名称', `data_scope` tinyint(4) DEFAULT 2 COMMENT '1全部 2本人 3本部门', `status` tinyint(4) DEFAULT 1, PRIMARY KEY (`id`), UNIQUE KEY `uk_role_code` (`role_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; CREATE TABLE `sys_permission` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `parent_id` bigint(20) DEFAULT 0, `permission_code` varchar(128) NOT NULL COMMENT '如 resume:download', `name` varchar(64) NOT NULL, `type` tinyint(4) DEFAULT 1 COMMENT '1菜单 2按钮', `sort_order` int(11) DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_permission_code` (`permission_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表'; CREATE TABLE `sys_user_role` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `role_id` bigint(20) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_role` (`user_id`, `role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; CREATE TABLE `sys_role_permission` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `role_id` bigint(20) NOT NULL, `permission_id` bigint(20) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';

初始化角色和权限数据时,我习惯把常用权限码统一写清楚:候选人投递是resume:submit、HR下载简历是resume:download、面试官填写评价是interview:evaluate、HR发布岗位是job:publish。角色绑权限时,管理员绑全部,HR绑岗位和简历的读写权,面试官只绑分配给自己的查询和评价权。这一步想清楚,后面接口注解直接引用权限码就行。

4.2 Spring Security过滤链与JWT工具类

Spring Boot 2.7及之前版本习惯用WebSecurityConfigurerAdapter,Spring Boot 3.x则推荐SecurityFilterChain的写法。这里以Spring Boot 3.x为例给出配置,核心是把/api/auth/login和OPTIONS预检请求放行,其余请求全部走认证过滤器。

@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .cors().and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests(auth -> auth .antMatchers("/api/auth/login", "/api/auth/captcha", "/error").permitAll() .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }

JWT工具类的核心就两个方法:生成Token和解析Token。生成时只放userId和roles两个字段,过期时间按access token 30分钟设置。解析时统一捕获签名异常和过期异常,不要把异常细节直接抛给前端。

public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor( "your-secret-key-here-change-me-to-a-long-random-string".getBytes()); private static final long EXPIRE_MS = 30 * 60 * 1000L; public static String createToken(Long userId, List<String> roles) { Date now = new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("roles", roles) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + EXPIRE_MS)) .signWith(KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }

记得JWT的签名密钥一定要放在配置文件里,用环境变量注入,不要硬编码写死在类里。密钥长度至少32字节,太短会直接启动报错。

4.3 基于注解的权限校验:一个切面搞定按钮级控制

先定义一个权限注解,放在方法上就能声明接口所需的权限码。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); }

再写一个AOP切面,在方法执行前取出当前登录用户的权限集合,检查是否包含注解里声明的权限码。这个切面是垂直越权的第一道闸门。

@Aspect @Component public class PermissionAspect { @Around("@annotation(requirePermission)") public Object checkPermission(ProceedingJoinPoint pjp, RequirePermission requirePermission) throws Throwable { LoginUser loginUser = SecurityUtils.getCurrentUser(); if (loginUser == null) { throw new BusinessException(401, "未登录"); } // 管理员可以直接放行 if (loginUser.isAdmin()) { return pjp.proceed(); } if (!loginUser.getPermissions().contains(requirePermission.value())) { throw new BusinessException(403, "无权限执行此操作"); } return pjp.proceed(); } }

Controller里用起来就非常简洁,接口和权限码一一对应,一眼就能看出哪个接口需要什么权限:

@PostMapping("/job/publish") @RequirePermission("job:publish") public Result<Void> publishJob(@RequestBody JobPublishRequest req) { jobService.publish(req); return Result.success(); }

权限集合在登录成功后加载到LoginUser对象里,并缓存到Redis和当前请求上下文。注意权限集合不要频繁查数据库,否则每个接口请求多几次数据库查询,压力全在DB上。

4.4 登录接口与防爆破的完整实现

登录接口的完整逻辑是:验证验证码、查账号、校验BCrypt密码、处理失败计数、签发Token。下面这段核心代码把账号级防爆破写进去了,Redis的key和过期时间都要精心设计。

@Service public class LoginService { @Autowired private UserMapper userMapper; @Autowired private RedisUtil redisUtil; public LoginResult login(LoginRequest req) { // 1. 校验验证码 String captchaKey = "captcha:" + req.getCaptchaId(); Object captcha = redisUtil.get(captchaKey); if (captcha == null || !captcha.toString().equalsIgnoreCase(req.getCaptcha())) { throw new BusinessException("验证码错误或已过期"); } redisUtil.del(captchaKey); // 2. 账号级防爆破 String failKey = "login:fail:user:" + req.getUsername(); Integer failCount = (Integer) redisUtil.get(failKey); if (failCount != null && failCount >= 5) { throw new BusinessException("操作过于频繁,请10分钟后再试"); } // 3. 校验账号密码 User user = userMapper.findByUsername(req.getUsername().trim()); if (user == null || user.getStatus() != 1 || !BCrypt.checkpw(req.getPassword(), user.getPassword())) { Long count = redisUtil.increment(failKey); if (count == 1L) { redisUtil.expire(failKey, 10 * 60); } throw new BusinessException("用户名或密码错误"); } // 4. 登录成功,清掉失败计数 redisUtil.del(failKey); // 5. 加载权限集合,签发令牌 List<String> roles = roleMapper.selectCodesByUserId(user.getId()); List<String> permissions = permissionMapper.selectCodesByUserId(user.getId()); String token = JwtUtil.createToken(user.getId(), roles); redisUtil.set("user:permission:" + user.getId(), permissions, 30 * 60); LoginUser loginUser = new LoginUser(user.getId(), user.getUsername(), roles, permissions); SecurityUtils.setCurrentUser(loginUser); return new LoginResult(token, user.getRealName()); } }

这段逻辑里最关键的细节是第三步的count == 1L判断。INCR第一次执行后Redis里没有过期时间,必须等返回值是1的时候设置EXPIRE,否则每次登录失败都刷新过期时间,锁定窗口会被无限拉长。密码校验放在用户为null判断的同一分支里,这样攻击者无法区分“用户名不存在”和“密码错误”,有效减少账号枚举风险。

IP级限流的思路和账号级一样,key改成login:fail:ip:请求IP,5分钟内超过30次直接拒绝1小时。注意如果走了Nginx层,要通过X-Forwarded-For取真实IP,避免所有请求都解析到Nginx的地址。

4.5 数据级越权校验:面试官只能看他该看的人

接口层权限注解管住了“谁能调这个接口”,但管不住“谁的数据”。比如面试官有resume:detail权限,但他能不能看某个具体候选人的简历,取决于这个候选人是否分配给他。下面这段Service层代码做了数据归属校验:

@Service public class ResumeService { @Autowired private InterviewAssignMapper assignMapper; public ResumeVO getResumeForInterviewer(Long candidateId) { LoginUser user = SecurityUtils.getCurrentUser(); // 管理员或HR跳过数据归属校验 if (user.isAdmin() || user.getRoles().contains("HR")) { return buildResume(candidateId, false); } // 面试官必须校验候选人是否分配给自己 LambdaQueryWrapper<InterviewAssign> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(InterviewAssign::getInterviewerId, user.getId()) .eq(InterviewAssign::getCandidateId, candidateId); if (assignMapper.selectCount(wrapper) == 0) { throw new BusinessException(403, "无权查看该候选人的简历"); } // 面试官只能看脱敏版 return buildResume(candidateId, true); } private ResumeVO buildResume(Long candidateId, boolean masked) { ResumeVO resume = resumeMapper.selectDetail(candidateId); if (masked) { resume.setMobile(maskPhone(resume.getMobile())); resume.setEmail(maskEmail(resume.getEmail())); } return resume; } }

这里的数据归属校验不能依赖前端传的面试官ID,必须从SecurityUtils里拿当前登录用户,否则任何人都可以伪造面试官ID绕过校验。脱敏逻辑统一写在Service层,不要在多个Controller里各写一份,避免某个接口漏了脱敏就把手机号暴露出来了。

5. 踩坑实录与排查经验:这些问题我替你趟过一遍

5.1 接口一直403:先查权限注解还是过滤器顺序

项目联调时最常遇到的情况就是接口返回403:URL没问题、权限码疑似配了,但请求死活进不去。第一件事先看JWT过滤器有没有正确解析Token。过滤器如果放在Spring Security的Filter链最前面,异常时还没有进入SecurityContext,权限切面自然拿不到用户信息,就会误判为未授权。

排查思路很固定:先看是否命中permitAll路径,再看过滤器是否把Token解析成功,最后看权限切面拿到的权限集合里有没有注解声明的权限码。大多数403其实是权限码没绑定到角色上,或者Redis缓存了旧权限。用Postman打接口时,可以在返回体的日志里打印当前用户ID、角色、权限集合,一目了然。

5.2 OPTIONS预检跨域被拦截:前后端分离第一坑

前后端分离部署后,前端调用后端接口经常出现白屏,浏览器控制台提示“CORS error”。问题出在Spring Security默认拦截所有请求,而浏览器跨域请求会先发一个不带Token的OPTIONS预检请求,直接被401拦掉了。

解决方法是两步:CORS配置里不要用allowedOrigins("*"),因为带Cookie请求时*域名会直接报错,要用allowedOriginPatterns("*");同时Spring Security放行所有OPTIONS请求。配置完成后,先用一个最简单的GET接口验证跨域通不通,再继续后面的权限调试,不要混在一起排查。

5.3 登录失败计数不生效:Redis INCR并发与过期细节

登录防爆破上线后,测试反馈连续输错10次密码账号也没被锁。排查发现计数器代码写成了先INCR再set expire,但因为多线程并发,EXPIRE把前面设置的过期时间覆盖掉了。另一个问题是在Redis集群模式下,INCR和EXPIRE是两个独立命令,不具备原子性,一旦Redis主从切换可能丢数据。稳妥的方案是用Lua脚本把INCR和EXPIRE写到同一个脚本里执行,保证原子性。单机开发环境下用我之前写的方式没问题,生产环境一定用Lua。

5.4 改完角色权限不生效:版本号比清缓存更省心

权限集合缓存到Redis之后,管理员在后台给角色新增了一个权限码,结果用户刷新页面后仍然提示无权限,因为Redis里还是旧权限集合。最简单的处理是在登录时给权限集合key加一个版本号:user:permission:{userId}:{version},version放在Redis里,角色权限变更时version+1,用户下一次请求发现版本号不一致就重新加载权限。这样比主动删缓存更可靠,因为删缓存存在删除失败或并发重建的竞态问题。

5.5 常见问题速查表

现象可能原因排查与解决
接口一直403权限码未绑定角色、JWT未解析、权限切面误判打印用户权限集合,比对注解权限码
前端跨域报错OPTIONS被拦截、CORS配置不允许带Cookie放行OPTIONS,用allowedOriginPatterns
登录失败计数不生效INCR与EXPIRE非原子、Redis重启丢失用Lua脚本保证原子性,配置AOF持久化
改权限后不生效Redis缓存了旧权限集合key加版本号,变更时version+1
求职者能访问HR接口接口未加权限注解,前端隐藏按钮被绕过所有写接口加@RequirePermission
候选人A看到候选人B简历查询接口缺少数据归属校验Service层校验当前用户与数据归属
上传的PHP文件执行了扩展名白名单被绕过、文件在可执行目录校验文件头魔数,重命名,存储目录禁止执行脚本
富文本评价弹脚本存储型XSS,原样保存HTML入库前Jsoup白名单过滤,展示时前端转义

这套方案做完之后,我自己最大的体会是:权限管理和安全加固不是上线前临时补的功能,而是从系统骨架搭建第一天就要设计进去的约束。招聘系统表面上是管简历,实际上管的是“谁能对简历做什么”这件事,边界定义清楚了,系统哪怕只有一两万行代码也站得稳。如果你正在写类似的项目,建议先把角色和数据权限的矩阵表画出来,再动手建表写接口,绝对比边写边补靠谱得多。一个小细节分享给你:在本地开发时把权限切面里的日志打开全量打印,联调期的403问题基本都能在十分钟内定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询