Spring Security+Redis后台管理系统:认证授权与会话管理实战
简介:这是一套面向Java Web开发者与企业级应用学习者的完整后台管理系统源码,基于Spring Boot、MyBatis、Spring MVC、Spring Security与Redis构建,适合希望掌握现代Java技术栈协同开发的中级开发者参考实践。项目将持久层映射、MVC分层、认证授权与缓存加速串联为可运行的整体,能帮助读者理解各组件在真实系统中的职责边界与集成方式。压缩包共326个文件,约1.76MB,以95个Java源码为核心,辅以45个JavaScript、33个HTML与19个CSS构成前端交互与页面结构,另有12个XML配置、1个SQL脚本及图片、字体等静态资源,目录组织清晰便于按模块查阅。目前已有959人学习下载。通过研读源码,读者可掌握Spring Security登录验证与权限控制流程、MyBatis定制化SQL映射写法,以及Redis缓存高频数据的落地思路,为构建复杂企业级应用积累可复用的工程经验。
1. 后台管理系统为什么值得重做一遍
接手一个后台管理系统,最怕的不是功能多,而是权限乱。我见过一个课程设计,登录只判断了“是不是管理员”,结果普通运营账号能直接调删除接口,前端藏了按钮,后端却毫无拦截——这就是典型的把权限当装饰。这套基于 Spring Boot + MyBatis + Spring MVC + Spring Security + Redis 的完整网站后台管理系统,要解决的核心就是“认证、授权、会话、数据”四件事在一个工程里怎么摆正位置。它适合正在做毕设或课程设计、需要一套能跑通又能讲清原理的读者,也适合刚进公司要独立交付一个后台骨架的开发者。下面按需求、架构、模块、避坑、验收的顺序,把这份方案拆开讲透。
2. 需求与选型:为什么是这套技术组合
2.1 后台系统的真实需求边界
后台管理系统看起来千篇一律,但真正决定工程结构的,是几个容易被忽略的需求点。第一是角色与权限的粒度:超级管理员、普通管理员、只读运营,这三类账号能看到的菜单和能调的接口必须不同,而且权限要能配置,不能写死在代码里。第二是会话状态:用户登录后,菜单、按钮权限、登录态需要在多次请求间保持一致,同时要支持强制下线。第三是数据操作的审计:谁在什么时候删了哪条记录,出问题要能查。第四是并发下的稳定性:后台往往多人同时操作,缓存和数据库的一致性不能靠运气。
这些需求决定了技术选型不能只图“能跑”。如果只用 Session 存登录态,多实例部署时就得做会话共享;如果权限判断散落在每个 Controller 里,后期加一个角色就要改几十处。所以这套方案把认证授权交给 Spring Security,把会话和热点数据交给 Redis,把持久层交给 MyBatis,Spring MVC 负责请求编排,Spring Boot 负责把这一切装配起来。选型不是堆技术,而是每个组件对应一个明确的责任。
2.2 各组件在方案里的职责划分
把五个技术点摆在一起,最容易犯的错是职责重叠。我一般的划分是这样的:Spring Security 只做两件事——认证(你是谁)和授权(你能干什么),它的过滤器链是请求进入业务代码前的第一道闸门;Redis 存登录令牌与用户权限快照,让每次请求不必回查数据库;MyBatis 负责 SQL 与对象映射,复杂查询用 XML 写清楚;Spring MVC 的 Controller 只做参数校验和结果封装,不写业务判断;Spring Boot 负责自动配置与依赖管理,把数据源、Redis 连接、安全配置统一收口。
这样划分的好处是,任何一个环节出问题都能快速定位。比如登录后菜单不显示,先看 Redis 里的权限快照有没有写进去,再看 Security 的授权规则有没有放行,而不是在 Controller 里到处打日志。下面这张表是各组件与需求的对应关系,做方案文档时可以直接引用。
| 组件 | 承担职责 | 对应需求 | 不负责的事 |
|---|---|---|---|
| Spring Security | 认证、授权、密码加密 | 角色权限隔离 | 业务数据查询 |
| Redis | 令牌存储、权限快照、热点缓存 | 会话一致性、强制下线 | 持久化业务数据 |
| MyBatis | SQL 映射、动态查询 | 数据审计、分页查询 | 事务边界控制 |
| Spring MVC | 请求路由、参数校验 | 接口规范 | 权限判断 |
| Spring Boot | 自动装配、配置管理 | 工程可维护性 | 业务逻辑 |
2.3 版本与依赖的取舍原则
版本选择上,通常的做法是让 Spring Boot 的父 POM 统一管理 Spring Security、Spring MVC 的版本,避免手动指定导致冲突。MyBatis 用 mybatis-spring-boot-starter,Redis 用 spring-boot-starter-data-redis,连接池选 Lettuce 即可。这里有个血泪经验:不要同时引入两个 Redis 客户端依赖,否则自动配置会打架,启动时报连接工厂重复定义的错。依赖清单保持精简,能少一个 starter 就少一个,后台系统不需要为了“看起来技术栈丰富”而引入用不上的组件。
3. 总体架构设计:分层结构与请求链路
3.1 四层结构与包组织方式
这套后台的代码结构按职责分成四层:接口层(controller)、业务层(service)、持久层(mapper)、实体与传输对象(entity / dto / vo)。包名按业务模块划分,比如system、content、log,每个模块内部再分 controller、service、mapper,而不是全工程一个大 controller 包。这样做的原因是后台系统模块多,按业务纵切比按技术横切更容易维护,加一个模块就是加一个包,不会牵一发动全身。
安全相关的配置单独放config包,包括 Security 配置、Redis 序列化配置、MyBatis 分页插件配置。实体类不直接返回给前端,统一用 VO 转换,避免把密码字段、内部状态字段暴露出去。这个习惯在答辩时很加分,因为评委常问“你怎么防止敏感字段泄露”,答案就在这层转换里。
3.2 一次请求的完整链路
理解请求链路是排查问题的前提。一个带登录态的请求进来,顺序是这样的:请求先经过 Spring Security 的过滤器链,JwtAuthenticationFilter(或令牌校验过滤器)从请求头取出令牌,去 Redis 查对应的用户信息与权限;查到则构造认证对象放入 SecurityContext,查不到直接返回 401。认证通过后进入 Spring MVC 的 DispatcherServlet,路由到对应 Controller,Controller 调 Service,Service 调 Mapper 访问数据库,结果逐层封装返回。
这条链路里有两个关键点:一是过滤器必须在业务代码之前执行,所以权限拦截不依赖 Controller 里的 if 判断;二是 Redis 查询失败要有降级策略,不能因为缓存挂了整个后台都登不进去。我一般会在过滤器里对 Redis 异常做捕获,记录日志后走数据库回查,虽然慢一点但保证可用。下面这段是过滤器里校验令牌的核心逻辑,注释写清了每一步的意图。
@Component public class TokenAuthFilter extends OncePerRequestFilter { @Autowired private RedisTemplate<String, Object> redisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1. 从请求头取令牌,约定 header 名为 Authorization String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { chain.doFilter(request, response); // 无令牌放行,由后续授权规则决定是否拦截 return; } // 2. 以令牌为 key 查 Redis 中的登录用户快照 String key = "login:token:" + token; Object cached = null; try { cached = redisTemplate.opsForValue().get(key); } catch (Exception e) { // 缓存异常不阻断请求,记录后走数据库回查(降级) log.warn("redis query failed, fallback to db", e); } if (cached != null) { LoginUser user = (LoginUser) cached; // 3. 构造认证对象,权限列表来自快照,避免每次回查数据库 UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(request, response); } }这段代码里,令牌的 key 前缀统一为login:token:,方便后续按前缀批量清理;权限列表存在LoginUser里,实现UserDetails接口,getAuthorities()返回角色与权限集合。降级分支是很多人漏掉的,缓存一抖整个后台登录态全丢,用户会以为系统崩了。参数上,令牌有效期建议在 Redis 里设置 TTL,比如 30 分钟滑动过期,每次请求命中后刷新 TTL,这样既保证安全又不会让活跃用户频繁重登。
3.3 权限模型的数据结构设计
权限模型用经典的 RBAC:用户表、角色表、权限表,外加两张关联表。用户与角色多对多,角色与权限多对多。权限的粒度落到“菜单 + 按钮 + 接口”三层,菜单权限控制左侧导航显示,按钮权限控制页面元素,接口权限由 Spring Security 的@PreAuthorize或配置类拦截。三层的权限编码统一格式,比如system:user:delete,前端和后端用同一套编码,避免两边对不上。
登录成功后,把用户的角色和权限编码一次性查出来,组装成LoginUser存入 Redis。这样每次请求的授权判断只读缓存,不碰数据库。权限变更时(比如管理员改了某个角色的权限),主动删除相关用户的缓存 key,下次请求重新加载。这个“写时失效”的策略比定时刷新更及时,实现也简单。数据表设计上,关联表加联合主键防止重复授权,权限表加父级 ID 支持树形菜单,这些细节在答辩时被问到能直接答上来。
4. 核心模块实现:认证、授权与数据层
4.1 登录认证与令牌签发
登录接口不经过 Security 的默认表单登录,而是自定义一个/auth/login接口,接收用户名密码,用AuthenticationManager做认证,成功后生成令牌写入 Redis。密码用 BCrypt 加密存储,数据库里不存明文,这是底线。认证失败要区分“用户不存在”和“密码错误”吗?从安全角度不建议区分,统一返回“用户名或密码错误”,防止被枚举账号。
令牌生成用 UUID 加时间戳即可,不必上 JWT,因为登录态已经存在 Redis 里,令牌只是个索引。这样做的好处是服务端可以随时让令牌失效(删 Redis key 就是强制下线),而纯 JWT 做不到即时失效。下面这段是登录服务的核心实现,注意认证成功后写缓存的顺序和 TTL 设置。
@Service public class AuthService { @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private UserMapper userMapper; public String login(String username, String password) { // 1. 交给 Security 的认证管理器校验账号密码 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(username, password)); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // 2. 查询该用户的角色与权限编码,组装权限快照 loginUser.setPermissions(userMapper.selectPermissionsByUserId(loginUser.getUserId())); // 3. 生成令牌并写入 Redis,TTL 30 分钟,后续请求命中后刷新 String token = UUID.randomUUID().toString().replace("-", ""); String key = "login:token:" + token; redisTemplate.opsForValue().set(key, loginUser, 30, TimeUnit.MINUTES); return token; } }参数说明:AuthenticationManager由 Security 配置类注入,内部调用UserDetailsService加载用户;selectPermissionsByUserId是一条多表关联查询,返回权限编码列表;TTL 设为 30 分钟是经验值,太短用户频繁掉线,太长强制下线不及时。刷新 TTL 的动作放在过滤器里,命中缓存后调用expire重置,形成滑动过期。这里有个容易翻车的点:LoginUser存入 Redis 前必须可序列化,否则取出来反序列化报错,通常用 JDK 序列化或 JSON 序列化,选一种并在配置里统一。
4.2 接口级授权与菜单动态渲染
接口授权用 Spring Security 的方法级注解,在 Controller 方法上标@PreAuthorize("hasAuthority('system:user:delete')"),权限编码与数据库里的一致。这样加一个接口权限只需加注解和配数据,不用改安全配置类。菜单渲染则是登录后调/auth/menus接口,根据当前用户的权限编码过滤菜单树,只返回有权限的节点,前端据此生成导航。按钮权限同理,前端拿到权限编码列表后控制按钮显隐,但后端接口必须独立校验,不能只靠前端藏按钮。
这里的设计要点是“前端控制体验,后端控制安全”。很多人做课设时只做了前端隐藏,后端接口裸奔,一测就穿。正确的做法是两边用同一套权限编码,前端从登录返回的权限列表里判断,后端用注解拦截,数据来源一致就不会出现“前端能点、后端拒绝”的尴尬。菜单树的构建在 Service 层完成,先查全量菜单,再按用户权限过滤,最后递归组装成树,递归深度一般不超过三层,不用担心性能。
4.3 MyBatis 持久层与分页查询
持久层用 MyBatis 的 XML 映射,复杂查询(多表关联、动态条件)写在 XML 里,简单单表操作用注解或通用 Mapper。分页用 PageHelper 插件,在 Service 层调用PageHelper.startPage(pageNum, pageSize)后紧跟查询,返回的PageInfo直接封装给前端。这里有个坑:startPage必须紧挨着查询语句,中间插了别的查询会导致分页错乱,这是 PageHelper 的线程变量机制决定的,血泪经验。
动态 SQL 用<if>和<where>标签拼条件,比如用户列表按用户名、状态、角色筛选,条件为空就不拼。要注意的是,拼接条件时参数用#{}而不是${},后者有 SQL 注入风险。审计日志表用 AOP 切面在 Service 方法执行后异步写入,记录操作人、接口、参数摘要、耗时,参数里的密码字段要脱敏。下面是一个带条件的分页查询示例,展示了 XML 里动态条件的写法。
<select id="selectUserPage" resultType="com.example.system.vo.UserVO"> SELECT u.id, u.username, u.status, r.role_name FROM sys_user u LEFT JOIN sys_user_role ur ON u.id = ur.user_id LEFT JOIN sys_role r ON ur.role_id = r.id <where> <if test="username != null and username != ''"> AND u.username LIKE CONCAT('%', #{username}, '%') </if> <if test="status != null"> AND u.status = #{status} </if> <if test="roleId != null"> AND r.id = #{roleId} </if> </where> ORDER BY u.id DESC </select>这段 SQL 的要点:用 LEFT JOIN 保证没有角色的用户也能查出来;<where>自动处理第一个 AND;LIKE 用 CONCAT 拼接,兼容不同数据库;排序字段固定,避免分页结果不稳定。参数说明:username模糊匹配,status精确匹配,roleId按角色过滤,三个条件都可为空。Service 层调用时先startPage再执行,返回PageInfo<UserVO>,前端拿到total和list渲染表格和分页条。
4.4 Redis 缓存策略与一致性处理
Redis 在这套系统里承担三块:登录令牌、权限快照、热点数据缓存(比如字典表、系统配置)。令牌和权限快照前面讲过,热点缓存的策略是“读多写少的数据缓存,写操作后删缓存”。比如字典表查询频繁但很少改,查询时先读缓存,未命中查库后写缓存并设 TTL;字典更新时删除对应 key,下次查询重新加载。不要用“更新缓存”代替“删除缓存”,并发下容易写出旧值。
缓存 key 的命名统一用模块:业务:标识的格式,比如sys:dict:status,方便排查和批量清理。序列化方式统一配置,key 用 String 序列化,value 用 JSON 序列化,这样在 Redis 客户端里能直接看懂内容,排查问题时不用反序列化。缓存穿透的防护,对空结果也缓存一个短 TTL 的空值标记,避免同一个不存在的 key 反复打数据库。这些细节写进方案文档,能体现工程思维,而不是“加了个 Redis 就完事”。
5. 避坑与排查:这套组合最容易翻车的地方
5.1 权限注解不生效
现象:Controller 方法上加了@PreAuthorize,但无权限的账号照样能调通。原因通常是没开方法级安全,Security 配置类上缺了@EnableGlobalMethodSecurity(prePostEnabled = true),或者注解加在了接口上而实现类没继承。解决:在配置类开启注解支持,注解统一加在 Controller 方法上,测试时用低权限账号实际调一次接口验证,别只看代码。
5.2 登录态在重启后全部丢失
现象:服务重启后所有用户被踢下线,需要重新登录。原因:令牌只存在 Redis 里没问题,但如果 Redis 没做持久化,或者开发时用了内存版 Redis,重启数据就没了。解决:生产环境给 Redis 开 RDB 或 AOF 持久化;开发环境接受这个行为,但要在文档里写清楚,避免答辩时被问“重启后登录态怎么办”答不上来。另一种情况是令牌存在本地 Map 里,那重启必丢,必须换成 Redis。
5.3 分页查询总数不对
现象:列表数据正确,但总条数显示为当前页条数,分页条只显示一页。原因:PageHelper 的startPage后面跟了多条查询,或者查询返回后手动转换了集合导致PageInfo丢失分页信息。解决:startPage紧跟目标查询,返回结果直接交给PageInfo包装,不要在中间做stream转换后再包装。排查时打印PageInfo的total和pageNum,一眼能看出问题。
5.4 缓存与数据库数据不一致
现象:后台改了配置,前台读到的还是旧值。原因:更新数据库后没删缓存,或者删缓存的时机在事务提交之前,事务回滚了缓存却已删(这个方向反而安全),更常见的是压根没删。解决:写操作在事务提交后删缓存,用TransactionSynchronization的afterCommit回调,或者简单点,Service 方法上加事务,方法内先更新库再删缓存,保证删缓存发生在事务内但库已更新。排查时对比 Redis 里的值和数据库的值,定位是没删还是删错 key。
5.5 跨域与令牌头丢失
现象:前端本地调试时接口 401,但用 Postman 调同一个接口正常。原因:浏览器跨域请求默认不带自定义头,或者预检请求(OPTIONS)被 Security 拦截返回 401。解决:Security 配置里放行 OPTIONS 请求,CORS 配置允许前端来源和Authorization头。这个坑在前后端分离的后台里几乎必踩,配置一次记一辈子。
6. 验收与答辩:怎么证明这套后台真的能用
验收这套系统,我一般不看页面多漂亮,而是按“权限、会话、数据、异常”四条线走一遍。权限线:准备三个不同角色的账号,分别登录,核对菜单显示、按钮显隐、接口调用三处是否一致,重点用低权限账号直接调高权限接口,必须返回 403。会话线:登录后手动删掉 Redis 里的令牌 key,再发请求应返回 401;修改某角色权限后,该角色用户下次请求应拿到新权限,验证缓存失效逻辑。数据线:新增、修改、删除各一条记录,核对数据库和列表页一致,分页总数正确,审计日志有对应记录。异常线:停掉 Redis 再登录,看是否有友好提示而不是白屏堆栈。
答辩时评委最爱问的三个问题,提前准备好答案:一是“你的权限是怎么拦截的”,答过滤器链加方法注解,前端只做体验;二是“令牌为什么不用 JWT”,答需要服务端可控的强制下线,Redis 存快照更灵活;三是“缓存和数据库怎么保证一致”,答写后删缓存加事务提交后回调。下面这张验收清单可以直接放进文档,逐项打勾。
| 验收项 | 操作步骤 | 预期结果 |
|---|---|---|
| 角色隔离 | 低权限账号调删除接口 | 返回 403,数据未变 |
| 强制下线 | 删除 Redis 令牌后请求 | 返回 401,跳转登录 |
| 权限变更 | 改角色权限后重新请求 | 菜单与接口权限同步更新 |
| 分页正确 | 翻到第二页并筛选 | 总数与筛选结果一致 |
| 缓存降级 | 停 Redis 后登录 | 有提示,不抛未捕获异常 |
有个习惯我从做第一个后台项目起就强制自己走一遍:每次改完权限相关代码,一定用最低权限的账号把所有菜单点一遍、把所有按钮对应的接口用工具调一遍,确认该拦的都拦住了。这套动作花不了十分钟,但能挡住答辩现场最尴尬的“演示时越权成功”。希望这套结构和清单能帮到你,把方案落到能跑、能讲、能验收的程度。
本文还有配套的精品资源,点击获取