前言
先贴一个项目地址 GitHub-Springboot-shiro-Redis
全文基于Maven进行管理
本文涉及范围
1.shiro在Springboot的共享Session配置
2.Shiro单用户登录的配置
3.Spring-data-jpa的部分不会赘述
个人学习研究,目前未在生产环境使用,有好的意见欢迎评论
共享Session的应用场景
应用场景
当用户访问系统服务时,会经过负载均衡,根据配置策略的不同,同一客户端访问的最终服务器可能不是同一台,为了保证用户的Session状态连续,则需要集群内各业务服务器共享Session。
目录
1.Maven 依赖引入shiro-spring-boot-web-starter
2.Shiro 的基本配置
3.Maven 依赖引入 spring-boot-starter-data-redis
4.针对集群共享需要进行的Shiro 扩展
5.集群共享Session下的单用户登录
1.Maven 依赖引入shiro-spring-boot-web-starter
编辑pom.xml
文件
<dependencies>
<!--······your dependencies -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.4.2</version>
</dependency>
</dependencies>
2.Shiro 的基本配置
这块可以参照Shiro的官方文档
Integrating Apache Shiro into Spring-Boot Applications
根据文档,我们为了实现shiro接管web应用的访问路径,仅需要实现一个自定义Realm
并注入Spring即可
2.1 创建自定义CustomeRealm
继承AuthorizingRealm
主要是实现2个继承的方法
AuthenticationInfo
doGetAuthorizationInfo:授权方法 定义如何获取用户的角色和权限的逻辑,给shiro做权限判断
CustomeRealm.java
class CustomRealm extends AuthorizingRealm {
@Autowired
UserService userService //用户对象的管理服务类,提供CURD操作
@Autowired
AuthService authService //权限验证服务类,可根据用户获取相应role和permission
/**
* 定义如何获取用户信息的业务逻辑,给shiro做登录
* @param token 登录TOKEN,包含了用户账号密码
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//登录TOKEN,包含了用户账号密码
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername();
//下列多个判断可根据业务自行增删
// 判断用户名是否不存在,如果不存在抛出异常
if (username == null) {
throw new AccountException("Null usernames are not allowed by this realm.");
}
//通过用户名,从数据库中查询出用户信息
User user = userService.findUserByName(username);
//如果用户不存在,则抛出账号不存在异常,由控制器决定返回消息为账号或密码错误
if (user == null) {
throw new UnknownAccountException("No account found for admin [" + username + "]");
}
// 如果用户账号为锁定状态,则不予登录。
if (user.isLocked()) {
throw new LockedAccountException("Account [" + username + "] is locked.");
}
//如果账号超出有效期,则不予登录
if (user.isCredentialsExpired()) {
String msg = "The credentials for account [" + username + "] are expired";
throw new ExpiredCredentialsException(msg);
}
//查询用户的角色和权限存到SimpleAuthenticationInfo中,这样在其它地方
//SecurityUtils.getSubject().getPrincipal() 就能拿出用户的所有信息,包括角色和权限
/** 将用户权限和角色存入User对象*/
user.setRoles(new HashSet<String>(["admin","teacher"]))
user.setPerms(new HashSet<String>(["blog:read","blog:search"]))
//构造验证信息返回
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.password, getName())
return info
}
/**
* 授权
* 定义如何获取用户的角色和权限的逻辑,返回包含用户角色和许可信息
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
//null usernames are invalid
if (principals == null) {
throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
}
//获取当前用户对应的User对象
User user = (User) getAvailablePrincipal(principals);
//创建权限对象
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//设置用户角色(user.getRoles()是一个Set<String>,【admin,student。。。】)
info.setRoles(user.getRoles())
//设置用户许可(user.getPerms()是一个Set<String>,【blog:read,blog:search。。。】)
info.setStringPermissions(user.getPerms())
return info
}
}
2.2 将CustomeRealm
注入Spring
1.首先将CustomeRealm
配置为Bean
2.然后将CustomeRealm
注入到DefaultWebSecurityManager
中
创建配置对象ShiroConfig.java
@Configuration
class ShiroConfig {
/**
* 注入自定义权限验证对象
*/
@Bean
public CustomRealm customRealm() {
CustomRealm realm = new CustomRealm();
return new CustomRealm();
}
/**
* SecurityManager是Shiro框架的核心,典型的Facade模式,
* Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务
* 将自定义CustomRealm 注入进SecurityManager
*/
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//自定义realm
securityManager.setRealm(customRealm);
return securityManager;
}
/**
* 为了保证实现了Shiro内部lifecycle函数的bean执行 也是shiro的生命周期
*/
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
/**
* setUsePrefix(true)用于解决一个奇怪的bug。在引入spring aop的情况下。
* 在@Controller注解的类的方法中加入@RequiresRole等shiro注解,会导致该方法无法映射请求,
* 导致返回404。加入这项配置能解决这个bug
*/
defaultAdvisorAutoProxyCreator.setUsePrefix(true);
return defaultAdvisorAutoProxyCreator;
}
/**
* shiro的统一权限判定
*/
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition(RequestMapService requestMapService) {
DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition();
//设置所有路径均不需要登录,可在控制器中添加Shiro注解进行覆盖
chain.addPathDefinition("/**", "anon");
return chain;
}
}
2.3 通过Http header 传递SessionID
(如无需要可略过)
如果前端通过Http协议的Header进行SessionID的发送
则需要实现一个继承DefaultWebSessionManager
的自定义SessionManager
然后注入DefaultWebSecurityManager
2.3.1 创建CustomeSessionManager.java
/**
* @author Hoody
* 自定义sessionId获取方式
* 从前端发送的header中获取SessionId,如果没有再从cookie中读取
*/
class CustomSessionManager extends DefaultWebSessionManager {
/** 存放 sessionID 的header key */
private static final String AUTHORIZATION = "X-Token"
private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request"
/**
* 重写getSessionId方法, 从前端发送的header中获取SessionId,如果没有再从cookie中读取
* @param request
* @param response
* @return
*/
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String id = WebUtils.toHttp(request).getHeader(AUTHORIZATION)
//如果请求头中有 Authorization 则其值为sessionId
if (!StringUtils.isEmpty(id)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE)
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id)
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE)
return id
} else {
//否则按默认规则从cookie取sessionId
return super.getSessionId(request, response)
}
}
}
2.3.2 注入CustomeSessionManager
到DefaultWebSecurityManager
在ShiroConfig.java
中添加CustomeSessionManager
Bean的注入
@Bean
public SessionManager sessionManager() {
CustomSessionManager customSessionManager = new CustomSessionManager();
return customSessionManager;
}
修改ShiroConfig.java
,将CustomSessionManager添加到securityManager
/**
* SecurityManager是Shiro框架的核心,典型的Facade模式,
* Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务
* 将自定义CustomRealm 注入进SecurityManager
*/
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//自定义realm
securityManager.setRealm(customRealm);
//自定义session管理
securityManager.setSessionManager(sessionManager());
return securityManager;
}
3.Maven 依赖引入 spring-boot-starter-data-redis
关于Redis的部分不做赘述
3.1在pom.xml
加入如下依赖
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3.2 application.yml
配置
spring:
redis:
host: localhost #redis服务PI
port: 6379 #服务端口
3.3 Redis 的基本操作
@Autowired
private RedisTemplate<String, Object> redisTemplate;
//保存
redisTemplate.opsForValue().set("key-1", "value-1");
//带有效期的保存
redisTemplate.opsForValue().set("key-1", "value-1", 120, TimeUnit.SECONDS);
//删除
redisTemplate.delete("key-1");
4.针对集群共享需要进行的Shiro 扩展
根据官方文档Shiro-Session Storage,如果要自定义Session的存储
需要自己实现一个SessionDao
对象来扩展Session的CURD
4.1 创建RedisSessionDAO
继承CachingSessionDAO
需要Override的4个方法是:doCreate: shiro创建session时,将session保存到redisdoUpdate: 当用户维持会话时,刷新session的有效时间doDelete: 当用户注销或会话过期时,将session从redis中删除doReadSession: shiro通过sessionId获取Session对象,从redis中获取
创建 RedisSessionDAO.java
public class RedisSessionDAO extends CachingSessionDAO {
//存入Redis中的SessionID的前缀
private static final String PREFIX = "SHIRO_SESSION_ID";
//有效期(后续使用时会增加时间单位)
private static final int EXPRIE = 1200;
//Redis 操作工具 详情见本文3.3章节
private RedisTemplate<Serializable, Session> redisTemplate;
//构造函数
public RedisSessionDAO(RedisTemplate<Serializable, Session> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* shiro创建session时,将session保存到redis
* @param session
* @return
*/
@Override
protected Serializable doCreate(Session session) {
//生成SessionID
Serializable serializable = this.generateSessionId(session);
assignSessionId(session, serializable);
//将sessionid作为Key,session作为value存入redis
redisTemplate.opsForValue().set(serializable, session);
return serializable;
}
/**
* 当用户维持会话时,刷新session的有效时间
* @param session
*/
@Override
protected void doUpdate(Session session) {
//设置session有效期
session.setTimeout(EXPRIE * 1000);
//将sessionid作为Key,session作为value存入redis,并设置有效期
redisTemplate.opsForValue().set(session.getId(), session, EXPRIE, TimeUnit.SECONDS);
}
/**
* 当用户注销或会话过期时,将session从redis中删除
* @param session
*/
@Override
protected void doDelete(Session session) {
//null 验证
if (session == null) {
return;
}
//从Redis中删除指定SessionId的k-v
redisTemplate.delete(session.getId());
}
/**
* shiro通过sessionId获取Session对象,从redis中获取
* @param sessionId
* @return
*/
@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
return null;
}
//从Redis中读取Session对象
Session session = redisTemplate.opsForValue().get(sessionId);
return session;
}
}
4.2 将RedisSessionManager
注入 SecurityManager
2个步骤:
1.容器中注册RedisSessionDao
2.获取SessionManager,并将自定义sessionDAO设置进去
编辑 ShiroConfig.java
//容器中注册RedisSessionDao
@Bean
public SessionDAO redisSessionDAO(RedisTemplate redisTemplate) {
return new RedisSessionDAO(redisTemplate);
}
/**
* 将SessionDao 加入 SecurityManager
*/
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm,SessionDAO sessionDAO) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//自定义realm
securityManager.setRealm(customRealm);
//获取SessionManager
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager)securityManager.getSessionManager();
//设置自定义sessionDAO
sessionManager.setSessionDAO(sessionDAO);
return securityManager;
}
至此,已经完成Shiro的集群共享Session
Service层代码如下
/**
* 登录
* @param UsernamePasswordToken token
*/
public Session login(String username,String password) {
Subject currentUser = SecurityUtils.getSubject()
currentUser.login(new UsernamePasswordToken(username, password));
currentUser.login(token)
//从session中取出用户
User user = (User) currentUser.getPrincipal()
if (user == null) throw new AuthenticationException()
//返回登录用户的信息给前台,含用户的所有角色和权限
return currentUser.getSession()
}
单机测试,可以通过启动应用登录后,重启应用,然后携带Session继续访问服务器即可
5. 集群共享Session下的单用户登录
单用户登录即单一账号,只能在一处登录,系统中不允许多个用户登录同一账号。
5.1 思路
1.用户登录时,在Redis中查询有没有以改账号登录的Session,如果没有则直接登录;如果有则删除已登录Session,达到踢出上一登录的目的。
2.目前用户登录信息Session保存在Redis中,各服务器均可操作,但是Redis中仅能通过SessionID进行查询,所以需要将用户名与当前SessionId进行关联。
3. 通过RedisSessionDao
进行Session操作的时候可将用户名
与SessionId
进行关联保存到Redis。
综上,需要对以下部分进行改造
1.RedisSessionDao
中参数为SimpleSession
对象,不包含用户名等信息,所以需要扩展创建自定义CustomeSession
对象,增加用户名信息等。
2. Session 对象由Shiro的SessionFactory
提供,所以也要重写一个自定义Session工厂类CustomSessionFactory
,并在其中创建CustomeSession
,添加用户名信息。
3. 在CustomeRealm
中添加Session判定与删除操作,调用RedisSessionDAO
查找是否已经有用户通过该账号登录,如果有则踢出上一处登录Session。
4. RedisSessionDAO
中增加方法getSessionByUsername
,访问Redis查询Session
5.2 创建自定义CustomeSession
我这里只增加了Username信息,根据需要可自行扩展
CustomeSession.java
/**
* 自定义Session ,增加了用户名信息
*/
public class CustomSession extends SimpleSession {
private String usernmae;
public String getUsernmae() {
return usernmae;
}
public void setUsernmae(String usernmae) {
this.usernmae = usernmae;
}
public CustomSession() {
this.usernmae = null;
}
public CustomSession(String host, String usernmae) {
super(host);
this.usernmae = usernmae;
}
}
5.3 创建自定义CustomSessionFactory
SessionFactory仅需要实现一个创建Session的方法即可。
/**
* 自定义SessionFactory
* 提供CustomSession的创建接口实现
*/
public class CustomSessionFactory implements SessionFactory {
@Override
public Session createSession(SessionContext initData) {
if (initData != null) {
String host = initData.getHost();
//通过initData获取到登录的参数,getParameter("username"); ,key值根据前端请求确定
String username = ((DefaultWebSessionContext) initData).getServletRequest().getParameter("username");
//如果不是匿名登录则创建包含信息的Session
if (host != null && username != null) {
return new CustomSession(host, username);
}
}
//匿名访问,直接创建空Session
return new CustomSession();
}
}
将CustomSessionFactory
注入Shiro
修改 ShiroConfig.java
//注册Bean
@Bean
public CustomSessionFactory customSessionFactory() {
return new CustomSessionFactory();
}
/**
* 将SessionDao 加入 SecurityManager
*/
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm,
CustomSessionFactory customSessionFactory,
SessionDAO sessionDAO) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//自定义realm
securityManager.setRealm(customRealm);
//获取SessionManager
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager)securityManager.getSessionManager();
//设置自定义sessionDAO
sessionManager.setSessionDAO(sessionDAO);
//设置自定义SessionFactory
sessionManager.setSessionFactory(customSessionFactory);
return securityManager;
}
5.4 CustomeRealm
中增加 session判定
在登录账号、有效期等验证之后加入checkIsLogin(token)
处理
checkIsLogin
1.通过SecurityUtils获取到SessionDao
2.通过username查询是否已经存在登录的Session
3.如果存在,则从Shiro中删除Session
修改 CustomeRealm.java
/**
* 定义如何获取用户信息的业务逻辑,给shiro做登录
* @param token 登录TOKEN,包含了用户账号密码
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//登录TOKEN,包含了用户账号密码
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername();
//下列多个判断可根据业务自行增删
//''''''省略其他判断 ,省略部分参考本文 2.1章节
//通过此方法对session进行单用户处理
this.checkIsLogin(upToken)
//查询用户的角色和权限存到SimpleAuthenticationInfo中,这样在其它地方
//SecurityUtils.getSubject().getPrincipal() 就能拿出用户的所有信息,包括角色和权限
/** 将用户权限和角色存入User对象*/
user.setRoles(new HashSet<String>(["admin","teacher"]))
user.setPerms(new HashSet<String>(["blog:read","blog:search"]))
//构造验证信息返回
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.password, getName())
return info
}
/**
* 单用户登录判断
* 1.通过SecurityUtils获取到SessionDao
* 2.通过username查询是否已经存在登录的Session
* 3.如果存在,则从Shiro中删除Session
* @param token
*/
private void checkIsLogin(UsernamePasswordToken token) {
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager()
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager()
RedisSessionDAO sessionDAO = (RedisSessionDAO) sessionManager.getSessionDAO()
Session session = sessionDAO.getSessionByUsername(token.getUsername())
if (session != null) {
sessionDAO.delete(session);
}
}
5.5 RedisSessionDAO 中增加方法getSessionByUsername,访问Redis查询Session
在session的CURD的几个步骤中加入 Username与SessionID 的键值对处理
修改完成后的RedisSessionDAO.java
,主要注意USERNAME_PREFIX
相关的操作
public class RedisSessionDAO extends CachingSessionDAO implements CacheManagerAware {
//Redis存储Session的key前缀
private static final String PREFIX = "SHIRO_SESSION_ID";
//Redis存储Username与SessionID 的key前缀
private static final String USERNAME_PREFIX = "USERNAME_SESSION_ID";
//过期有效期
private static final int EXPRIE = 10000;
//Redis操作工具类
private RedisTemplate<Serializable, Object> redisTemplate;
//构造函数
public RedisSessionDAO(RedisTemplate<Serializable, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
//创建session
@Override
protected Serializable doCreate(Session session) {
//通过Session转型为自定义Session
CustomSession customSession = (CustomSession) session;
//生成SessionId
Serializable serializable = this.generateSessionId(session);
assignSessionId(session, serializable);
//保存k-v:sessionID-Session对象到 Redis
redisTemplate.opsForValue().set(PREFIX + serializable, session);
//判断如果当前用户不是是匿名登录
if (customSession.getUsernmae() != null) {
//在Redis中保存 键值对 用户名-SessionID
redisTemplate.opsForValue().set(USERNAME_PREFIX + customSession.getUsernmae(), serializable);
}
return serializable;
}
//更新session有效期
@Override
protected void doUpdate(Session session) {
session.setTimeout(EXPRIE * 1000);
CustomSession customSession = (CustomSession) session;
//将sessionid作为Key,session作为value存入redis,并设置有效期
redisTemplate.opsForValue().set(PREFIX + session.getId(), session, EXPRIE, TimeUnit.SECONDS);
//判断如果当前用户不是是匿名登录
if (customSession.getUsernmae() != null) {
//在Redis中更新 键值对 用户名-SessionID的有效期
redisTemplate.opsForValue().set(USERNAME_PREFIX + customSession.getUsernmae(), session.getId(), EXPRIE, TimeUnit.SECONDS);
}
}
@Override
protected void doDelete(Session session) {
if (session == null) {
return;
}
CustomSession customSession = (CustomSession) session;
redisTemplate.delete(PREFIX + session.getId());
//判断如果当前用户不是是匿名登录
if (customSession.getUsernmae() != null) {
/在Redis中删除 键值对 用户名-SessionID
redisTemplate.delete(USERNAME_PREFIX + customSession.getUsernmae());
}
}
/**
* 从Redis读取Session,
* 如果未读取到,有2种情况,Session过期,或者被重新登录踢出,则抛出异常
* @param sessionId
* @return
*/
@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
return null;
}
//尝试读取Session
Session session = (Session) redisTemplate.opsForValue().get(PREFIX + sessionId);
//如果未读取到有2种情况,Session过期,或者被重新登录踢出,则抛出异常
if (session == null) {
throw new SignOutException("Account Sign in offsite");
}
return session;
}
//根据用户名获取Session
public Session getSessionByUsername(String username) {
String sessionId = this.getSessionIdByUsername(username);
return doReadSession(sessionId);
}
//根据用户名获取SessionId
public String getSessionIdByUsername(String username) {
return (String) redisTemplate.opsForValue().get(USERNAME_PREFIX + username);
}
}
6.总结
很早之前接触了Shiro,但是都是单机应用,最近在尝试了解分布式与集群相关的东西。所以尝试写了这个文档作为从Springboot迈向下一步的记录。 希望能够帮助到你。 总的来说还是要多看看看官方文档,阅读源码