Hoody's Blog
Shiro Springboot 集群共享Session (Redis)+单用户登录

前言

先贴一个项目地址 GitHub-Springboot-shiro-Redis
全文基于Maven进行管理
本文涉及范围
1.shiro在Springboot的共享Session配置
2.Shiro单用户登录的配置
3.Spring-data-jpa的部分不会赘述

个人学习研究,目前未在生产环境使用,有好的意见欢迎评论

共享Session的应用场景

网络拓扑.jpg

当用户访问系统服务时,会经过负载均衡,根据配置策略的不同,同一客户端访问的最终服务器可能不是同一台,为了保证用户的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 注入CustomeSessionManagerDefaultWebSecurityManager

ShiroConfig.java 中添加CustomeSessionManagerBean的注入

    @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保存到redis
doUpdate: 当用户维持会话时,刷新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迈向下一步的记录。 希望能够帮助到你。 总的来说还是要多看看看官方文档,阅读源码

添加新评论,支持Markdown格式