SpringBoot整合Shiro+Redis实现用户认证授权与分布式会话方案

发布时间:2026/10/23 23:30:07
SpringBoot整合Shiro+Redis实现用户认证授权与分布式会话方案

SpringBoot+Shiro+Redis 整合实现用户认证授权,这套组合在中小型项目里实在太常见了。我自己接过好几个这类需求,真要说起来,踩过的坑不比写过的代码少。如果你正准备给项目加一套登录鉴权,又不想直接上Spring Security那一大坨配置,那Shiro配Redis确实是个非常务实的选择。这篇文章就把我实际整合过程中的思路、配置、代码和问题排查全部整理出来,照着做可以少走很多弯路。

1. 整体设计与思路拆解

1.1 为什么选 Shiro 而不是 Spring Security

很多刚开始做认证授权的同学会纠结这个问题。Spring Security背靠Spring家族,功能确实强大,社区资料也多,但它的学习曲线偏陡峭,过滤链的配置逻辑也比较绕,新手往往花了两三天连登录流程还没跑通。Shiro的设计哲学则相反,它把认证、授权、会话管理、加密这几个核心能力全部抽象成简单的API,概念少、上手快,一个简单的登录校验只需要理解Subject、SecurityManager、Realm这三个核心组件就能跑起来。

从实际项目角度讲,如果你的系统是标准的单体应用,用户量在百万以下,对安全框架的要求主要是“能快速实现登录、记住我、角色权限控制”,那Shiro是完全够用的。它自带SessionManager,结合Redis之后还能解决分布式场景下的会话共享问题,已经覆盖了绝大多数业务系统的需求。我见过不少公司内部的管理后台、运营系统、中小型电商后端,用的都是这套方案。

当然,Shiro也不是没有缺点。相比Spring Security,它的过滤链自定义能力弱一些,文档也不够细致,遇到一些偏门问题往往要靠翻源码解决。而且Shiro的社区活跃度近年有所下降,新功能迭代比较慢。选型的时候你要评估自己的团队情况:如果大家都没用过Shiro,但熟悉Spring生态,那直接上Spring Security可能反而更顺;如果项目工期紧、需求简单,或者团队成员已经有Shiro经验,那Shiro这套组合性价比极高。

1.2 引入 Redis 解决了什么问题

单机部署的时候,Shiro自带的Session管理完全够用,Session数据存在JVM内存里,登录状态直接保存在服务器本地。可一旦系统需要多节点部署,或者服务重启、发布,问题就来了:用户在A节点登录,下次请求被负载均衡转发到B节点,B节点的内存里没有这个Session,用户就被判定为未登录,体验非常割裂。

传统的解决方案是Session黏滞,让同一个用户的请求始终转发到同一台机器,但这只能算规避,节点故障或重启时问题依然存在。引入Redis之后,Session数据被集中存储在外部缓存中,所有节点共享同一份会话数据。用户无论请求到哪个节点,都能从Redis正确读取登录状态。同时,Redis的高读写性能让Session的存取几乎不影响接口响应时间。

除了存Session,Redis还承担了权限缓存的角色。Shiro默认每次请求都会执行Realm的授权方法,如果你的授权逻辑里有数据库查询,在高并发场景下这个开销会被放大。把角色和权限列表缓存到Redis后,同一用户的权限校验直接命中缓存,大幅减少了数据库的压力。这套设计在真正的生产环境里效果很明显,我有一次压测,加了Redis缓存之后QPS提升了将近30%。

1.3 整体架构与数据流转过程

这套方案的核心数据流转可以简单概括为:请求进来,Shiro过滤链判断是否有登录凭证;有则从Redis中取出Session,恢复用户的登录状态;需要鉴权时,从Redis缓存取出权限信息进行校验;登录成功后,用户信息和Session写入Redis;退出登录时,删除Redis中的会话数据。

具体到一次登录请求的时序:用户提交用户名密码,Shiro把请求包装成一个UsernamePasswordToken,交给SecurityManager;SecurityManager委托给配置好的Realm去做认证——Realm里我们写的是先查数据库用户信息,再用密码加密规则比对密码;认证成功后,Shiro根据配置生成Session并存入Redis,同时用户主体信息也被缓存起来。后续每次请求经过过滤链时,Shiro的SessionDAO都会先去Redis里查这个Session是否还存在,从而判断用户是否有效登录。

这套流程把“认证”“会话”“缓存”三个环节通过Redis串起来,结构清晰,每个环节都可以独立做扩展。比如后续要加“记住我”功能,只要调整Cookie和Session的有效期逻辑;要加多终端登录限制,也只需在Session管理上做文章。

2. 核心细节解析与实操要点

2.1 Shiro 三大核心组件的工作机制

Shiro的工作模型围绕Subject、SecurityManager、Realm展开。用生活化的类比来解释这三者的关系:Subject是“当前操作的人”,SecurityManager是“门卫”,Realm是“员工花名册”。

  • Subject:代表当前用户,封装了用户的所有安全操作。在代码里,你可以随时通过SecurityUtils.getSubject()拿到它,然后用subject.login(token)做登录,用subject.isAuthenticated()判断是否已登录,用subject.hasRole("admin")判断角色。它本质上是Shiro与当前线程绑定的一个上下文对象,简化了安全操作的调用方式。
  • SecurityManager:整个Shiro的心脏,负责协调认证、授权、会话管理等所有内部组件。它自己不干活,而是把任务分发给其他组件执行。在SpringBoot整合时,我们需要在配置类里创建一个SecurityManager的Bean,并把它绑定到Shiro的过滤链上。
  • Realm:数据访问层的桥接器,Shiro通过它获取用户的认证信息和授权信息。默认的Realm什么都不做,我们必须写一个自定义Realm继承AuthorizingRealm,重写doGetAuthenticationInfo(认证时用)和doGetAuthorizationInfo(授权时用)这两个方法。这是整个整合方案里最核心的代码部分。

理解这三个组件的职责边界,排起错来会清晰很多。比如登录失败,问题大概率出在Realm里的认证逻辑;权限校验不通过,就要检查doGetAuthorizationInfo里返回的权限集合是否正确。

2.2 过滤链配置:anon、authc、roles、perms 的含义

Shiro的过滤链是通过配置URL和对应的过滤器来实现访问控制的,这也是它最让人头疼但又最灵活的部分。常用的几个过滤器要搞清楚:

  • anon:匿名访问,不需要登录就可以访问。通常用来放行登录接口、注册接口、静态资源、验证码接口。
  • authc:必须登录后才能访问。配置了authc的路径,如果用户未登录会被重定向到登录页面或返回未授权提示。
  • roles:必须拥有指定角色才能访问。它需要参数,比如roles[admin],表示只有admin角色可以访问。
  • perms:必须拥有指定权限才能访问,同样需要参数,比如perms[user:add]。

配置的顺序非常重要。Shiro的过滤链是“第一次匹配优先”的,也就是说一条请求在前面匹配到了anon,就会立刻放行,后面的authc根本不会生效。我见过有同学把/**=authc配置在前面,结果所有请求都要登录,后面配置的放行规则完全失效。正确的做法是:放行规则写前面,兜底的拦截规则写最后,具体化的路径写前面,通配的路径写后面。

2.3 Redis 在 Shiro 整合中的两种核心用途

Redis这套方案里扮演两个完全不同的角色,很多人在配置时容易混淆,导致出现一些很隐蔽的问题。

第一个用途是作为Session的存储介质。Shiro把原本存在JVM的Session搬到Redis里,需要我们自己实现一个RedisSessionDAO,继承AbstractSessionDAO,重写doCreate、doRead、doUpdate、doDelete这几个方法。Shiro每次创建、读取、更新、删除Session时都会调用这个DAO。写入Redis的key我们通常设计成shiro_session_xxx这样的格式,value是序列化后的Session对象。

第二个用途是权限数据的缓存。Shiro的Realm里有一个AuthorizationCache,默认内存实现。我们把它替换成RedisCache实现后,用户权限信息会以shiro_cache_xxx的key形式存入Redis,缓存有效期可以单独设置。两种数据虽然都放在Redis里,但生命周期和用途不一样,建议在Redis的key前缀上做明确区分,避免相互干扰。

2.4 序列化方案:Java 序列化 vs JSON 序列化

把Session对象和权限对象存入Redis之前,必须经过序列化。这里有个很多人踩过的坑:RedisTemplate默认的序列化器是JdkSerializationRedisSerializer,它要求存储的对象必须实现Serializable接口。如果你的用户实体类没实现Serializable,运行时就会抛序列化异常。

用Java原生序列化虽然简单,但存储的内容可读性极差,全是乱码和类路径信息,占用空间也大。更推荐的做法是用FastJson或Jackson做序列化,这样Redis里看到的是清晰的JSON结构,排查问题时一眼就能看出存储的内容。不过要注意,用JSON序列化时,从Redis读出来的对象是JSON反序列化后的副本,不是原来的对象引用,在极端情况下可能存在数据一致性问题。考虑到Session和权限信息通常是只读使用,这个权衡是可以接受的。

我在生产环境里用的是改造过的RedisSessionDAO,内部统一走JSON序列化,这样Redis里的数据键值对一目了然,出了问题的定位效率高很多。

3. 实操过程与核心环节实现

3.1 Maven 依赖引入

首先在pom.xml里引入必要的依赖。我用的版本组合是经过实际验证的,可以直接抄:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.9.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.26</version> </dependency>

需要注意,shiro-spring-boot-starter这个依赖在不同版本下的兼容性差异挺明显。如果你用的是Spring Boot 2.x,建议Shiro版本选1.8.x以上,1.9.x更稳;如果你是Spring Boot 3.x,那要特别注意Shiro对Jakarta命名空间的支持问题,最好找专门的适配版本。

3.2 自定义Realm实现认证与授权

自定义Realm是整套方案的核心代码。我习惯创建一个名为UserRealm的类,继承AuthorizingRealm,里面实现认证和授权两个方法。

先看认证方法:

@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; String username = usernamePasswordToken.getUsername(); // 根据用户名查询数据库 User user = userService.findByUsername(username); if (user == null) { throw new UnknownAccountException("用户不存在"); } if (user.getStatus() == 0) { throw new LockedAccountException("账号已被锁定"); } // 返回认证信息,密码的比对由CredentialsMatcher完成 return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); }

注意这里有个关键设计:我没有在Realm里手动比对密码,而是把数据库查出的加密密码返回给Shiro,由配置的CredentialsMatcher去自动比对。这样做的好处是,密码校验逻辑集中管理,后续如果更换加密算法,只需要替换Matcher的实现,不影响Realm的其它逻辑。

再看授权方法:

@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { User user = (User) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 查询用户角色 Set<String> roles = roleService.findRolesByUserId(user.getId()); // 查询用户权限 Set<String> perms = permissionService.findPermsByUserId(user.getId()); info.setRoles(roles); info.setStringPermissions(perms); return info; }

这个方法的调用时机很特殊:不是每次请求都会执行,而是首次访问需要角色或权限校验的接口时才触发,而且结果会被缓存。所以把权限查询逻辑写在这里非常合适,配合Redis缓存可以大幅减少数据库查询。

3.3 Shiro 核心配置类编写

配置类负责把Realm、SecurityManager、SessionDAO、过滤链全部串起来。我直接给出一个可以运行的配置骨架:

@Configuration public class ShiroConfig { @Bean public UserRealm userRealm() { return new UserRealm(); } @Bean public SecurityManager securityManager(UserRealm userRealm, RedisSessionDAO sessionDAO) { DefaultWebSecurityManager manager = new DefaultWebSecurityManager(); manager.setRealm(userRealm); manager.setSessionManager(sessionManager(sessionDAO)); // 开启Redis缓存权限数据 manager.setCacheManager(redisCacheManager()); return manager; } @Bean public SessionManager sessionManager(RedisSessionDAO sessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionDAO(sessionDAO); // Session有效期,单位毫秒,这里设置为2小时 sessionManager.setGlobalSessionTimeout(7200000); return sessionManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 登录接口 factoryBean.setLoginUrl("/api/login"); // 未授权跳转地址 factoryBean.setUnauthorizedUrl("/api/unauthorized"); // 过滤链配置 Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); // 放行登录、注册、验证码接口 filterChainDefinitionMap.put("/api/login", "anon"); filterChainDefinitionMap.put("/api/register", "anon"); filterChainDefinitionMap.put("/api/captcha", "anon"); // 静态资源放行 filterChainDefinitionMap.put("/static/**", "anon"); // 用户相关接口需要登录 filterChainDefinitionMap.put("/api/user/**", "authc"); // 管理员接口需要admin角色 filterChainDefinitionMap.put("/api/admin/**", "roles[admin]"); // 增加权限示例 filterChainDefinitionMap.put("/api/user/add", "perms[user:add]"); // 其余接口需要登录 filterChainDefinitionMap.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } }

有一处细节要提醒:filterChainDefinitionMap的类型一定是LinkedHashMap,保证有序性。如果用了HashMap,过滤链的匹配顺序会变得不可预测,表面上看着配置没问题,实际跑起来却会出现各种奇怪的访问异常。

另外,CacheManager的Redis实现需要我们自己写一个类,继承AbstractCacheManager,创建RedisCache。这个类的代码量不大,核心是把Cache接口的get、put、remove方法映射到Redis的读写删除操作上。

3.4 RedisSessionDAO 与 RedisCache 实现

这部分是整合过程中的硬骨头,直接给出可用的核心实现。

RedisSessionDAO继承AbstractSessionDAO,关键是序列化方式。我选择用FastJson处理,先把Session转成JSON字符串存入Redis,读取时再反序列化。还有一个细节是key的过期时间设置——Session本身有超时时间,Redis的key也要设置一个对应的TTL,防止Session永久残留在Redis里变成脏数据。

@Component public class RedisSessionDAO extends AbstractSessionDAO { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String SESSION_KEY_PREFIX = "shiro_session:"; private static final long SESSION_TIMEOUT = 7200L; @Override protected Serializable doCreate(Session session) { Serializable sessionId = generateSessionId(session); assignSessionId(session, sessionId); saveSession(session); return sessionId; } @Override protected Session doReadSession(Serializable sessionId) { String key = SESSION_KEY_PREFIX + sessionId; String sessionStr = redisTemplate.opsForValue().get(key); if (sessionStr == null) { return null; } return JSON.parseObject(sessionStr, SimpleSession.class); } private void saveSession(Session session) { String key = SESSION_KEY_PREFIX + session.getId(); String sessionStr = JSON.toJSONString(session); redisTemplate.opsForValue().set(key, sessionStr, SESSION_TIMEOUT, TimeUnit.SECONDS); } @Override public void update(Session session) throws UnknownSessionException { saveSession(session); } @Override public void delete(Session session) { String key = SESSION_KEY_PREFIX + session.getId(); redisTemplate.delete(key); } }

RedisCache的实现稍微复杂,主要是把缓存key映射到Redis key,并统一管理过期时间。这里我直接用一个StringRedisTemplate操作,避免序列化器不一致带来的问题。

3.5 登录接口与退出登录的实现

登录接口的逻辑很直观,核心就两行代码:创建token,调用subject.login。

@PostMapping("/login") public Result login(@RequestBody LoginRequest loginRequest) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken( loginRequest.getUsername(), loginRequest.getPassword() ); try { subject.login(token); // 登录成功后,可以从subject里拿到用户信息 User user = (User) subject.getPrincipal(); return Result.success("登录成功", user); } catch (UnknownAccountException e) { return Result.error("用户不存在"); } catch (IncorrectCredentialsException e) { return Result.error("密码错误"); } catch (LockedAccountException e) { return Result.error("账号被锁定"); } catch (AuthenticationException e) { return Result.error("认证失败"); } }

退出登录更简单,一行代码:

@GetMapping("/logout") public Result logout() { Subject subject = SecurityUtils.getSubject(); subject.logout(); return Result.success("退出成功"); }

调用subject.logout()之后,Shiro会清掉当前Subject的身份信息,并通过我们实现的RedisSessionDAO删除对应的Session数据。从Redis里检查一下key对应的数据是否被清掉,就能确认退出逻辑是否生效。

3.6 权限注解的使用方式

除了过滤链配置,Shiro还支持在接口方法上使用注解来控制权限,粒度更细,用起来也比URL配置更清晰。

@RestController @RequestMapping("/api/user") public class UserController { // 需要登录才能访问 @RequiresAuthentication @GetMapping("/info") public Result getUserInfo() { User user = (User) SecurityUtils.getSubject().getPrincipal(); return Result.success(user); } // 需要admin角色 @RequiresRoles("admin") @PostMapping("/delete") public Result deleteUser(@RequestParam Long userId) { userService.deleteById(userId); return Result.success("删除成功"); } // 需要user:add权限 @RequiresPermissions("user:add") @PostMapping("/add") public Result addUser(@RequestBody User user) { userService.add(user); return Result.success("新增成功"); } }

使用注解方式有个先决条件:必须在配置类里开启Shiro注解支持,并且确保Shiro的拦截器配置了可代理的目标对象。Spring Boot整合Shiro后,默认会通过AOP方式支持注解,但需要额外加入一个Bean配置,让Spring的切面能够拦截到这些注解。

3.7 密码加密方案的选型与实现

密码安全是整个认证系统里绝对不能含糊的部分。早期很多系统直接用MD5存密码,这在如今的安全环境下是远远不够的。我推荐使用MD5加随机盐的方式,甚至直接用BCrypt,成本低效果好。

以MD5加盐为例,注册时生成一个随机salt,密码加密的逻辑如下:

// 生成随机盐 String salt = RandomStringUtils.randomAlphanumeric(8); // 加密,迭代次数可以根据需要调整 String encryptedPassword = new Md5Hash(rawPassword, salt, 2).toHex();

数据库里存储的是加密后的密码和盐。登录时,Shiro会通过CredentialsMatcher进行密码比对,我们要在ShiroConfig的UserRealm里设置对应的Matcher:

@Bean public UserRealm userRealm() { UserRealm realm = new UserRealm(); // 使用带盐的MD5,迭代2次 HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("MD5"); matcher.setHashIterations(2); realm.setCredentialsMatcher(matcher); return realm; }

这里面有一处非常容易踩坑:HashedCredentialsMatcher默认认为数据库里存储的密码是Hex字符串,如果当时加密用的是Base64或者其他编码,比对就会一直失败。所以注册时的编码方式和Matcher的预期必须严格一致,改起来很费劲。

如果要从根本上提高安全性,可以直接用BCrypt。BCrypt的哈希自带盐,不需要额外字段存salt,校验时直接用BCrypt.matches(rawPassword, encryptedPassword)即可。Shiro也有对应的BCryptCredentialsMatcher可以配置。代价是BCrypt的计算开销大一些,但对登录这种低频操作来说完全不是问题。

4. 整合过程中的常见问题与排查实录

4.1 Session 失效或每次请求都重新登录

这个问题的典型表现是:前端调用登录接口成功了,但下一次请求就被过滤链拦截,判定为未登录。

排查思路:先检查Redis里面有没有写入Session数据。如果Redis里根本没有shiro_session开头的key,说明SessionDAO的写入逻辑没有被执行或执行失败。常见原因有几种:

  • RedisTemplate的序列化配置有问题,Session对象写入Redis时报序列化异常,被Shiro内部吞掉了。
  • SessionDAO没有正确注入到SessionManager,Shiro还是用的默认内存Session实现。检查配置里sessionManager的setSessionDAO是否真的生效。
  • Cookie的名称或路径不对,浏览器没有保存Shiro生成的SessionID。用浏览器的开发者工具看一下请求Cookie的情况。

如果是前端部署在另外一个域名的场景,还要考虑Cookie跨域的问题。Shiro默认的Cookie只在同域名下有效,跨域时需要配置Cookie的domain属性,或者改成前后端分离模式,前端手动携带SessionID。

4.2 密码一直匹配不上的问题

密码比对失败的排查核心是:注册时的加密逻辑和登录时的校验逻辑必须完全一致。

最常见的坑是加盐方式不对。注册时用new Md5Hash(password, salt, 2),但登录时Matcher拿到的ByteSource如果不一致,结果必然不一样。在Realm里返回AuthenticationInfo时,盐的构造方式必须和注册时保持一致。比如注册时用的是随机字符串salt,那么Realm返回时也要用ByteSource.Util.bytes(user.getSalt())。

另外一个坑是迭代次数。我遇到过一个情况:数据库表里有历史数据,老数据的迭代次数是1次,新数据是2次,结果老用户全部登录失败。解决思路是渐进式迁移,登录成功后检测当前用户的加密参数,如果是老版本,在登录成功的同时用新参数重新加密一遍密码并更新数据库。

4.3 Redis 缓存导致权限变更不生效

这是最容易让人困惑的问题之一。管理员在后台修改了某个用户的角色或者权限,但该用户下次访问接口时还是旧权限,甚至需要重启服务才生效。

原因是权限信息被缓存到了Redis,而且是按用户维度缓存的。只要不主动清理,Redis中的缓存会一直沿用旧数据。解决思路有三种:

  • 在权限变更的接口里,主动清理对应用户权限的缓存。需要拿到权限缓存Manager,调用clearCache方法。
  • 给权限缓存设置较短的过期时间,比如30分钟,牺牲一些性能换取准实时性。
  • 在权限变更后,强制该用户重新登录,通过登录流程重建权限缓存。

实际项目里我比较推荐第一种思路,在业务代码里预留缓存清理逻辑。虽然要多写一点代码,但权限变更的响应是几乎实时的,运营和管理同事的体验会好很多。

4.4 RedisSessionDAO 反序列化报错

这种情况在Session数据是旧版本写入、代码升级后重新读取时容易发生。比如之前用Java原生序列化写入的Session,代码改成JSON序列化后,旧的Redis数据还在,新代码尝试用JSON解析,直接抛异常。

解决思路是平滑升级:改代码之前,先把Redis里的Session数据一次性清掉。发布时设置session的有效期短一些,让旧Session尽快过期。更好的方案是代码里做兼容处理,读数据失败时catch异常并返回null,让Shiro对这个Session重新走创建流程。

4.5 权限注解不生效的排查

在Spring Boot项目里启用Shiro注解,需要确认两个方面:一是自定义Realm的Bean必须被Spring管理,二是Shiro的注解扫描需要开启。常见的配置是引入一个AuthorizationAttributeSourceAdvisor的Bean,它的作用是把Shiro的注解和Spring AOP关联起来。

如果配置了还是不生效,优先检查是不是类没有被Spring扫描到,或者接口方法用了private修饰。Shiro的注解是通过AOP代理实现的,private方法是不会走代理的,所以注解必须放在public方法上。

5. 这个方案的扩展与优化方向

5.1 基于 Redis 的限流与防刷

既然Redis已经集成进来了,顺手加一个接口防刷的机制是很自然的扩展。在登录接口上做限制,比如同一个IP一分钟内最多尝试5次登录,超过则直接拒绝。实现也不复杂,用Redis的INCR和EXPIRE命令就能搞定。

String key = "login:limit:" + ipAddress; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, 1, TimeUnit.MINUTES); } if (count != null && count > 5) { throw new RuntimeException("登录尝试次数过多,请稍后再试"); }

这个逻辑放在登录接口的最前面,拦截效果非常直接。它也是目前很多互联网产品在用的登录防暴力破解手段。

5.2 记住我功能与退出策略

Shiro自带RememberMe功能,开启后用户关闭浏览器,SessionId仍然保存在本地Cookie中,下次访问自动恢复登录状态。Redis的引入让RememberMe的实现更有优势:因为Session数据在Redis里,即使用户换了节点,只要Cookie中的SessionId还在,依然能正确恢复登录状态。

实现RememberMe需要在登录时设置token的rememberMe属性为true,并在SecurityManager中配置一个rememberMeManager,把Cookie的有效期调长。要注意的是,RememberMe状态下,subject.isAuthenticated()为false,但subject.isRemembered()为true。如果你的代码里用isAuthenticated()做判断,未真正登录就拒绝访问的接口,需要特别注意这个细节。

5.3 集群环境下的多节点会话一致性

这套方案在集群环境下的表现是比较理想的。因为所有Session数据都存在Redis,每个节点读到的都是同一份数据,天然解决了会话一致性的问题。唯一要注意的是,多个节点共享同一个Redis时,序列化配置必须一致,否则A节点写入的数据会被B节点反序列化失败。

另一个要考虑的点是,Redis的持久化策略要对齐公司的运维规范。如果Redis是纯缓存模式、允许数据丢失,那么Redis重启后所有用户会集体掉线,必须重新登录。如果希望高可用,可以开启Redis的持久化,或者用主从模式+哨兵。这块属于运维范畴,但在方案设计阶段就应该想清楚,免得后面上线时出大问题。

6. 实操心得总结

这套SpringBoot+Shiro+Redis的方案,我从第一次接触到今天,前前后后在好几个项目里落地过。回过头来看,最核心的收获是:技术选型一定要结合团队的实际能力和项目的真实规模,不要为了炫技去选一个用不上的框架。

Shiro在中小型项目里依然是非常能打的方案,搭配Redis之后的分布式会话能力完全够用。而且它最大的优势在于概念直观,新同学上手速度快,出现问题后定位路径非常短。相比Spring Security,同样的功能在Shiro里的实现代码量大概能少30%到40%,这在项目周期紧的时候是实打实的优势。

有一点我觉得值得养成习惯:正式上线前,一定要对认证授权模块做一次专门的测试,重点覆盖Session过期、权限变更、多节点切换、并发登录这几个场景。这些边角问题在单机开发和测试环境里很难暴露,但一旦上了生产,用户报障的往往就是这类问题。

最后再分享一个排查小技巧:如果你在整合过程中碰到了诡异的Session、权限问题,第一步永远是去Redis里看看数据是什么样的。数据在,问题就在读数据的环节;数据不在,问题就在写数据的环节。这比反复看代码猜原因高效得多。