用SpringBoot+Spring Security为竞赛系统打造完整权限体系

用SpringBoot+Spring Security为竞赛系统打造完整权限体系 简介面向高校毕业设计与课程设计场景的 Spring Boot 竞赛管理系统项目整合 Spring Boot、Spring Security、JWT、Vue.js、Element UI、axios 与 MyBatis Plus覆盖用户认证、权限控制、竞赛信息管理等典型业务模块适合计算机相关专业学生参考或二次开发。压缩包共 53 个文件约 230KB以 31 个 Vue 组件页面、11 个 JavaScript 逻辑文件为主配合 3 个 CSS、3 个 JSON、1 个 SCSS 样式与配置以及 README 说明前端代码分层清晰便于快速定位入口与接口调用。已有 85 人学习浏览印证其在校园项目实践中的参考价值。通过这套项目可掌握前后端分离开发中 JWT 鉴权、Spring Security 过滤链配置、Vue 路由与状态管理、axios 封装等关键实现同时从完整目录结构中理解毕业设计常见模块划分与代码组织方式适合作为课程设计报告撰写或系统演示的起点。1. 用SpringBoot Spring Security给竞赛系统先立权限墙一个大学生竞赛管理系统业务上绕不开这几件事学生报名、上传作品、教师评审、管理员发布赛项。如果只把CRUD写完上线后第一个月就会出乱子——学生能翻到别人的作品附件教师能改不属于自己赛项的分数学生能直接调接口把自己状态改成“已通过”。这些问题不是业务逻辑没写好而是认证和授权从根上就没立住。SpringBoot负责把竞赛业务的开发速度提起来Spring Security负责把所有请求挡在权限边界之外。这个组合的典型落地方式是SpringBoot提供接口Spring Security处理登录认证、会话维护、接口授权再配合RBAC模型把“谁能干什么”变成数据库里的几行记录。这篇顺着从理论到实现的路径把整个系统的安全体系拆开讲清楚。适合正在做竞赛管理类系统的开发者也适合想从SSH老项目迁到SpringBoot的人。2. 竞赛系统的RBAC权限模型表结构、角色边界与选型理由2.1 三种角色的权限边界与“超管”陷阱竞赛管理系统里最常见的角色划分是学生、教师、管理员。学生能报名、上传作品、查看自己的成绩教师能创建赛项如果学校允许、评审作品、录入分数管理员管用户、管赛项配置、管全局参数。这里有一个常见的认知陷阱以为“管理员”应该拥有一切权限于是代码里到处写if (user.getRole() ADMIN)。等系统跑起来才发现管理员误操作改掉评审分数比学生越权更可怕。正确的做法是把管理员也看作一个普通角色只是它被分配了“用户管理”“赛项管理”等具体权限点。权限的最小单位为操作角色只是权限的集合而不是硬编码的超级身份。2.2 五张核心表与一段可直接执行的建表SQLRBAC的标准落法是五张表用户表、角色表、用户角色关联表、权限菜单表、角色权限关联表。竞赛业务表赛项表、报名表通过teacher_id或student_id与用户表外键关联权限判断时再联合角色表查询。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), email VARCHAR(100), enabled TINYINT(1) DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL UNIQUE, role_name VARCHAR(50), -- 角色描述如学生、评审教师、校级管理员 description VARCHAR(200) ); CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL ); CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(100) NOT NULL UNIQUE, menu_name VARCHAR(50), parent_id BIGINT DEFAULT 0 ); CREATE TABLE sys_role_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL );这段SQL里sys_menu.perm_code存的是像competition:create、score:update这样的权限标识字符串而不是前端菜单路径。把权限标识和菜单解耦之后后端校验可以直接用hasAuthority(competition:create)判断前端菜单的增删不影响后端权限规则。enabled字段用来做账号禁用这是竞赛系统里很实用的一个开关比如学生毕业或教师调离后不删账号只禁用。2.3 为什么这里选RBAC而不是在代码里写if判断对比项硬编码角色判断RBAC模型新增角色改代码、重新部署数据库插一行记录调整权限逐个找if分支改角色与权限的关联记录审计追溯无法回答“谁有这个权限”一次关联查询即可竞赛系统的角色数量虽然少通常只有3到4类但每个角色的权限点会随业务演进持续调整。比如第一版学生只能上传作品第二版允许学生修改已提交的作品这个变化如果走硬编码就要去代码里加判断走RBAC给“学生”角色加一条entry:update权限记录即可。Spring Security的hasAuthority天然支持这种权限点校验配合PreAuthorize注解就能在方法级别做控制。3. Spring Security登录认证落地过滤器链、UserDetailsService与自定义登录接口3.1 从过滤器链认识Spring Security的认证入口Spring Security不是一个独立运行的框架它寄生在Servlet过滤器链上。一个HTTP请求进入SpringBoot应用后会先经过Tomcat的Filter链再进入DispatcherServlet。Spring Security通过FilterChainProxy在这条链上注册了一批过滤器按顺序处理认证逻辑。SecurityContextPersistenceFilter先从Session里取出已保存的SecurityContext没有就新建一个空的UsernamePasswordAuthenticationFilter负责拦截登录请求从请求体里取出用户名和密码封装成Authentication对象后交给AuthenticationManager做校验校验成功后SecurityContext会被放回SecurityContextHolder同时写进Session。后续请求进来SecurityContextHolder里已经有认证信息就不需要再走登录逻辑。理解这条链的意义在于写代码时要知道每个配置项最终落在了哪个过滤器上。比如sessionManagement配置控制的是SessionManagementFiltercsrf配置控制的是CsrfFilter。出了问题看启动日志里打出的过滤器链顺序就能定位到是哪一环没配对。3.2 SecurityFilterChain一份能跑起来的SecurityConfigSpring Security 5.7版本之后官方推荐用SecurityFilterChain的Bean方式替换掉曾经的WebSecurityConfigurerAdapter继承写法。竞赛系统前后端分离登录走JSON接口所以表单登录和HTTP Basic都要关掉。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; Configuration public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { // BCrypt每次生成的hash都不同但matches能正确比对适合存库 return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .formLogin(form - form.disable()) .httpBasic(basic - basic.disable()) .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/competitions/public).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/teacher/**).hasAnyRole(TEACHER, ADMIN) .requestMatchers(/api/student/**).authenticated() .anyRequest().authenticated()); return http.build(); } }这段配置的逻辑分几层看csrf.disable()是因为前后端分离后没有页面表单CSRF Token的传递成本高于收益SessionCreationPolicy.IF_REQUIRED表示只有需要时才创建Session避免每个匿名请求都产生服务端Session。requestMatchers的匹配规则从上往下生效先声明/api/teacher/**允许TEACHER和ADMIN那么ADMIN访问教师接口时就放行不需要在/api/admin/**里再重复配。注意hasRole会自动给传入的值加ROLE_前缀数据库里存的角色标识要写成ROLE_ADMIN。3.3 UserDetailsService实现与密码加密选型Spring Security只认UserDetailsService接口——你给它用户名它返回一个UserDetails对象。这个对象里封装了密码和权限列表AuthenticationManager拿到之后和用户提交的密码做比对。import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserMapper userMapper; public UserDetailsServiceImpl(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在: username); } org.springframework.security.core.userdetails.User.UserBuilder builder org.springframework.security.core.userdetails.User.builder(); return builder .username(user.getUsername()) .password(user.getPassword()) .roles(userMapper.selectRoleCodesByUserId(user.getId())) .disabled(!user.getEnabled()) .build(); } }这段代码里有三个关键点。第一密码是从库里查出来的BCrypt密文绝不能拿明文做比对。第二roles()方法接收的是不带ROLE_前缀的角色代码Spring会帮你拼接。第三disabled(!user.getEnabled())把sys_user.enabled字段映射到了Spring Security的账号状态判断上管理员在后台禁用账号后该账号立刻无法登录。密码加密用BCryptPasswordEncoder它的特点是同一密码每次加密结果不同但matches(rawPassword, encodedPassword)能正确校验。建表时password字段长度至少留到60BCrypt的hash长度是60个字符。3.4 自定义登录接口与AuthenticationManager装配Spring Boot的自动装配会把AuthenticationManager准备好但不会直接作为Bean暴露。从AuthenticationConfiguration里取出来再配合自定义的登录接口使用。import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; public AuthController(AuthenticationManager authenticationManager) { this.authenticationManager authenticationManager; } PostMapping(/login) public String login(RequestBody LoginRequest loginRequest) { UsernamePasswordAuthenticationToken authToken new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword()); // 这一步会触发UserDetailsService PasswordEncoder的校验流程 Authentication authentication authenticationManager.authenticate(authToken); SecurityContextHolder.getContext().setAuthentication(authentication); return 登录成功; } }AuthenticationManager.authenticate()内部会依次调用DaoAuthenticationProvider、UserDetailsServiceImpl和PasswordEncoder.matches()任何一个环节失败都会抛出AuthenticationException。登录成功后把Authentication放进SecurityContextHolder后续请求会通过SecurityContextPersistenceFilter从Session里恢复这个上下文。这里没有手写Token默认走的是Session机制JSESSIONID由服务端通过Set-Cookie下发。需要单独配置一个AuthenticationManager的Bean否则无法注入import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.context.annotation.Bean; Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }异常处理方面登录接口要捕获BadCredentialsException密码错误和DisabledException账号禁用分别返回“用户名或密码错误”和“账号已被禁用”不要把Spring Security的原始异常直接抛给前端。4. 接口级权限控制URL规则、PreAuthorize与数据级权限4.1 URL规则管粗粒度方法注解管细粒度在第3章的SecurityFilterChain配置里requestMatchers已经处理了“谁可以访问哪个URL前缀”的问题。URL规则适合做粗粒度的拦截——/api/admin/**只能管理员进/api/student/**登录用户都能进。但竞赛系统里很多权限判断依赖请求参数比如“修改作品”要求修改者是作品的主人“录入分数”要求评分人是该赛项的指定评委。URL匹配做不到这种粒度需要在方法上做二次校验。方法级权限控制的开关是EnableMethodSecurity。Spring Security 5.6之前叫EnableGlobalMethodSecurity新版本已经废弃了旧注解直接用新的即可。import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.context.annotation.Configuration; Configuration EnableMethodSecurity public class MethodSecurityConfig { }开启之后PreAuthorize注解就生效了它在方法执行之前先做SpEL表达式判断表达式结果为false直接抛出AccessDeniedException方法体不会执行。4.2 在竞赛管理中使用PreAuthorize的三个实例看三个竞赛系统里最典型的场景。第一个是管理员发布赛项只有ADMIN角色能做import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; PostMapping(/api/admin/competitions) PreAuthorize(hasAuthority(competition:create)) public String createCompetition(RequestBody Competition competition) { competitionService.save(competition); return 创建成功; }这里用了hasAuthority而不是hasRole对应sys_menu.perm_code里的competition:create。区别在于hasRole强制要求ROLE_前缀hasAuthority则是精确匹配权限字符串。如果初始化了角色和权限的关联数据推荐用hasAuthority做细粒度控制角色与权限点可以自由组合。第二个是删除作品附件只有作品所属者或者管理员能操作import org.springframework.security.access.prepost.PreAuthorize; DeleteMapping(/api/student/entries/{id}) PreAuthorize(hasRole(ADMIN) or entryPermissionChecker.canDelete(authentication.principal, #id)) public String deleteEntry(PathVariable Long id) { entryService.deleteById(id); return 删除成功; }SpEL里用beanName.method(...)可以调用已注册的Spring Bean。这里把判断逻辑抽到EntryPermissionChecker里避免在注解里写复杂的逻辑组合。authentication.principal拿到的是之前UserDetailsService里构造的User对象#id对应方法参数。第三个是评分接口限定只有该赛项的指定评委才能打分PutMapping(/api/teacher/scores/{entryId}) PreAuthorize(competitionPermissionChecker.isReviewer(authentication.principal, #entryId)) public String scoreEntry(PathVariable Long entryId, RequestBody ScoreRequest score) { scoreService.updateScore(entryId, score.getScore()); return 评分成功; }isReviewer的典型实现是查competition表里的reviewer_id字段和当前登录用户的ID比对。这样配置之后“教师角色能访问评分接口”和“该教师能评这个赛项”被拆成了两层前者由URL规则把关后者由方法注解结合业务数据把关。4.3 数据级权限教师只能看自己赛项的做法与边界方法注解能挡“不该访问的人”但挡不住“访问了不该访问的数据”。考虑这样一个接口——查询赛项详情返回结果包含全部参赛作品列表。教师A访问赛项ID为10的接口但ID为10的赛项是教师B负责的。PreAuthorize如果只判断“当前用户是TEACHER角色”那教师A就拿到了教师B的赛项数据。常见做法是分两步。第一步在查询层过滤数据把teacher_id作为SQL查询条件public Competition getCompetitionForTeacher(Long teacherId, Long competitionId) { return competitionMapper.selectByIdAndTeacherId(competitionId, teacherId); }SELECT * FROM competition WHERE id #{competitionId} AND teacher_id #{teacherId}第二步用PostAuthorize兜底防止查询结果意外返回给无权用户import org.springframework.security.access.prepost.PostAuthorize; PostAuthorize(returnObject null or returnObject.teacherId authentication.principal.id) public Competition getCompetitionDetail(Long competitionId) { return competitionMapper.selectById(competitionId); }注意PostAuthorize的局限它在方法执行之后才拦截数据已经从数据库查出来了如果表达式校验失败虽然响应会被拦截掉但方法内部的日志、统计等副作用已经发生了。所以在数据敏感的场景里第一道防线必须是SQL层面的数据隔离PostAuthorize只做兜底。4.4 常用权限表达式速查表表达式含义竞赛系统场景hasRole(ADMIN)当前用户拥有ROLE_ADMIN角色管理员操作赛项配置hasAnyRole(TEACHER,ADMIN)拥有其中任一角色即通过教师可进评分接口管理员兜底hasAuthority(score:update)拥有score:update权限点精确控制评分权限与角色解耦isAuthenticated()已登录即可访问学生查看自己已有报名记录#id authentication.principal.id方法参数与登录用户ID一致学生只能修改自己的作品beanName.check(...)自定义Bean校验逻辑判断当前用户是否为赛项指定评委denyAll()拒绝所有访问临时关闭某个高危接口时使用5. 落地阶段的配置排错版本差异、CORS、CSRF与403定位5.1 Spring Boot 2.7到3.xWebSecurityConfigurerAdapter过时与javax换jakarta很多人跟着旧教程写代码会遇到“springboot版本太高”导致的编译失败。Spring Security 5.7开始WebSecurityConfigurerAdapter被标记为过时Spring Security 6.0直接移除了这个类。如果你新建的SpringBoot项目是2.7.x以上configure(HttpSecurity http)的继承写法会提示无法覆盖父类方法。3.x版本还有另一个坑Servlet API从javax.servlet换成了jakarta.servlet。意味着OncePerRequestFilter、Filter等类的import路径全要改旧代码迁移时这一条最容易漏报错信息通常是ClassNotFoundException: javax.servlet.Filter。我一般会在项目初始化时直接定下版本基线SpringBoot 2.7.x对应Spring Security 5.8SpringBoot 3.x对应Spring Security 6.x。代码里统一用SecurityFilterChain加Lambda表达式配置不碰已经过时的实现类。5.2 前后端分离的CORS与CSRF配置竞赛系统的前端如果跑在Vue开发服务器比如localhost:5173后端接口在localhost:8080浏览器会拦截跨域请求。Spring Security的过滤链默认会把不带正确CORS头的跨域请求挡掉不能只靠CrossOrigin注解。import org.springframework.context.annotation.Bean; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration new CorsConfiguration(); configuration.setAllowedOrigins(List.of(http://localhost:5173)); configuration.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); configuration.setAllowedHeaders(List.of(*)); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/api/**, configuration); return source; }然后在SecurityFilterChain里加上http.cors(cors - cors.configurationSource(corsConfigurationSource()));setAllowCredentials(true)是关键它允许前端请求携带CookieJSESSIONID。但注意setAllowedOrigins不能和AllowCredentials同时使用*必须显式写清来源地址。CSRF和CORS的搭配容易糊涂如果你连csrf.disable()都写了那么CSRF就不是跨域问题的来源如果没写跨域请求会因为没有CSRF Token被拒绝报403。很多前后端分离项目里的403其实是CSRF校验失败而不是权限不足。5.3 403不等于没权限三条快速定位路径遇到403先别急着改权限表达式。比赛系统里最常见的几种403成因第一请求带上了跨域预检请求OPTIONS但CORS配置没放行。检查CorsConfigurationSource里的setAllowedMethods是否包含OPTIONS不包含的话浏览器预检直接失败控制台会提示CORS error。第二CSRF没关且请求未带Token。确认SecurityFilterChain里是否执行了csrf.disable()如果没执行且走的是自定义登录接口就要在登录请求头里追加X-CSRF-TOKEN或者直接关掉。第三角色前缀对不上。hasRole(ADMIN)要求数据库或UserDetails里必须存在ROLE_ADMIN这个授权标识。如果UserDetailsService里用了authorities()而不是roles()返回的权限列表里没有ROLE_前缀那hasRole永远返回false。排查时把Security的日志开到DEBUG级别直接看过滤器链和处理结果是最高效的办法logging: level: org.springframework.security: DEBUG日志里会逐条打出访问的URL命中了哪些匹配规则、最终走的哪个过滤器、异常原因是什么。看到AccessDeniedException出现在AuthorizationFilter之前说明是认证阶段出了问题出现在AuthorizationFilter说明是授权规则拒绝如果请求都没进到SecurityFilterChain就被拦那就是CORS层或Tomcat层的错误。6. 生产化收尾Redis会话共享、OAuth2.0扩展与安全加固6.1 Spring Session Redis处理多实例部署竞赛管理系统上线后通常不止跑一个实例两台服务器轮询负载均衡时Session默认存在各自的内存里。用户在第一台服务器登录成功第二次请求被分发到第二台服务端查不到Session直接返回未登录。解决方案是Spring Session加Redis把Session从本地内存搬到集中存储。引入依赖后配置spring.session.store-typeredis即可SpringBoot会自动替换掉HttpSession的实现所有getSession()的调用透明切换到Redis存储。dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency要注意的是Redis里的Session数据有序列化格式排查问题时别直接用redis-cli get去看用Redis Desktop Manager或redis-cli --scan --pattern spring:session:*确认key是否存在。Session失效时间在server.servlet.session.timeout里设置竞赛系统建议设30分钟评审教师经常填一半分数去查资料时间太短会丢评分内容。6.2 预留OAuth2.0统一身份认证的接入位不少高校有统一身份认证平台竞赛系统最终要对接学校账号体系。Spring Security对OAuth2.0 Client的支持已经非常成熟只需要在配置里加依赖和认证服务器参数不用自己实现授权码流程。实际接入时application.yml里要配spring.security.oauth2.client.registration和provider两段信息需向校信息中心申请client-id和client-secret。改造的时候建议保留原有用户名密码登录方式作为备用通道等统一认证稳定了再切换默认登录入口。6.3 加固与验收安全边界才有意义落地到这个阶段还差两类工作。一类是防护SpringBoot常见漏洞——springboot heapdump 敏感信息泄露漏洞要重点处理Spring Actuator的/actuator/heapdump端点如果暴露在公网攻击者可以直接下载JVM堆内存文件从中提取密码、Token等敏感信息生产环境要把management.endpoints.web.exposure.include配置为只暴露health和info并把Actuator端口限定在内网访问。另一类是审计验收逐个确认关键接口的权限和用户禁用后的状态同步。最终验证建议用无痕窗口开三个角色账号分别登录后访问同一个受保护接口确认返回码分别是200和403再禁用其中一个账号验证enabled字段的即时生效。这套检查跑完权限墙才算真正立住了。本文还有配套的精品资源点击获取