Shiro使用Redis实现分布式会话与信息缓存

4,117 阅读5分钟

1. 前言

在上一篇文章《SpringBoot极简集成Shiro》中,讲解了SpringBoot极简集成Shiro的过程,但因为是极简集成,所以有些地方不适合生产环境,可以进行优化,如:集群环境下的Session的分布式会话;每次用户授权时,都需要走数据库查询等问题。

所以,本篇文章将在上一篇文章的基础上,通过Redis来实现如下功能:

  1. 实现Session分布式会话功能
  2. 将用户的身份认证信息和授权信息缓存在Redis中,避免多次查询数据库

2. 项目结构

在之前的基础上,项目结构基本没有改变,就是增加一个ShiroSessionManager.java,用于获取SessionId

项目结构.png

3. 编码实现

3.1 pom导入

<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>1.3.2</version>
</dependency>

<dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.4.0</version>
</dependency>
<!--增加Redis相关依赖-->
<dependency>
        <groupId>org.crazycake</groupId>
        <artifactId>shiro-redis</artifactId>
        <version>3.1.0</version>
</dependency>

3.2 application.yml

增加了redis的配置

server:
  port: 8903
spring:
  application:
    name: lab-user
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/laboratory?charset=utf8
    username: root
    password: root
  redis:
    host: 127.0.0.1
    port: 6379
    password: 123456
mybatis:
  type-aliases-package: cn.ntshare.laboratory.entity
  mapper-locations: classpath:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true

3.3 ShiroSessionManager.java

/**
 * 自定义Session获取规则,采用http请求头authToken携带sessionId的方式
 * 登录成功后,会返回会话的sessionId,前端需要在请求头中加入该sessionId
 */
public class ShiroSessionManager extends DefaultWebSessionManager {

    public final static String HEADER_TOKEN_NAME = "token";

    public ShiroSessionManager() {
        super();
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String id = WebUtils.toHttp(request).getHeader(HEADER_TOKEN_NAME);
        if (StringUtils.isEmpty(id)) {
            // 按照默认规则从cookie中获取SessionId
            return super.getSessionId(request, response);
        } else {
        // 从Header头中获取sessionId
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
            request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
            return id;
        }
    }
}

3.4 ShiroConfig.java

该文件在之前的基础上改动了如下几个方面:

  1. 开启了身份认证和授权信息的缓存
  2. 新增了redisCacheManager,sessionManager
  3. 新增了redisSessionDAO

代码如下:

import cn.ntshare.laboratory.realm.UserRealm;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private Integer redisPort;

    @Value("${spring.redis.password}")
    private String redisPassword;

    @Bean
    public UserRealm userRealm() {
        UserRealm userRealm = new UserRealm();
        // 开启缓存
        userRealm.setCachingEnabled(true);
        // 开启身份验证缓存,即缓存AuthenticationInfo信息
        userRealm.setAuthenticationCachingEnabled(true);
        // 设置身份缓存名称前缀
        userRealm.setAuthenticationCacheName("authenticationCache");
        // 开启授权缓存
        userRealm.setAuthorizationCachingEnabled(true);
        // 这是权限缓存名称前缀
        userRealm.setAuthorizationCacheName("authorizationCache");

        return userRealm;
    }

    @Bean
    public DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(userRealm());
        // 使用Redis作为缓存
        securityManager.setCacheManager(redisCacheManager());
        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }

    /**
     * 路径过滤规则
     * @return
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        shiroFilterFactoryBean.setLoginUrl("/login");
        shiroFilterFactoryBean.setSuccessUrl("/");
        Map<String, String> map = new LinkedHashMap<>();
        // 有先后顺序
        map.put("/login", "anon");
        map.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }

    /**
     * 开启Shiro注解模式,可以在Controller中的方法上添加注解
     * 如:@
     * @param securityManager
     * @return
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    @Bean
    public SessionManager sessionManager() {
        ShiroSessionManager sessionManager = new ShiroSessionManager();
        sessionManager.setSessionDAO(redisSessionDAO());
        return sessionManager;
    }

    @Bean
    public RedisManager redisManager() {
        RedisManager redisManager = new RedisManager();
        redisManager.setHost(redisHost);
        redisManager.setPort(redisPort);
        if (redisPassword != null && !("").equals(redisPassword)) {
            redisManager.setPassword(redisPassword);
        }
        return redisManager;
    }

    @Bean
    public RedisSessionDAO redisSessionDAO() {
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setRedisManager(redisManager());
        // 设置缓存名前缀
        redisSessionDAO.setKeyPrefix("shiro:session:");
        return redisSessionDAO;
    }

    @Bean
    public RedisCacheManager redisCacheManager() {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager());
        // 选择属性字段作为缓存标识,这里选择account字段
        redisCacheManager.setPrincipalIdFieldName("account");
        // 设置信息缓存时间
        redisCacheManager.setExpire(86400);
        return redisCacheManager;
    }
}

3.5 UserRealm.java

这个文件中认证和授权的部分并未改动,只增加了清楚缓存的方法

public class UserRealm extends AuthorizingRealm {

    @Autowired
    private UserService userService;

    @Autowired
    private RoleService roleService;

    @Autowired
    private PermissionService permissionService;

    // 用户授权
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("执行了一次授权");
        User user = (User) principalCollection.getPrimaryPrincipal();
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        List<Role> roleList = roleService.findRoleByUserId(user.getId());
        Set<String> roleSet = new HashSet<>();
        List<Integer> roleIds = new ArrayList<>();
        for (Role role : roleList) {
            roleSet.add(role.getRole());
            roleIds.add(role.getId());
        }
        // 放入角色信息
        authorizationInfo.setRoles(roleSet);
        // 放入权限信息
        List<String> permissionList = permissionService.findByRoleId(roleIds);
        authorizationInfo.setStringPermissions(new HashSet<>(permissionList));

        return authorizationInfo;
    }

    // 用户认证
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authToken) throws AuthenticationException {
        System.out.println("执行了身份认证");
        UsernamePasswordToken token = (UsernamePasswordToken) authToken;
        User user = userService.findByAccount(token.getUsername());
        if (user == null) {
            return null;
        }
        return new SimpleAuthenticationInfo(user, user.getPassword(), getName());
    }

    /**
     * 清除当前授权缓存
     * @param principalCollection
     */
    @Override
    public void clearCachedAuthorizationInfo(PrincipalCollection principalCollection) {
        super.clearCachedAuthorizationInfo(principalCollection);
    }

    /**
     * 清除当前用户身份认证缓存
     * @param principalCollection
     */
    @Override
    public void clearCachedAuthenticationInfo(PrincipalCollection principalCollection) {
        super.clearCachedAuthenticationInfo(principalCollection);
    }

    @Override
    public void clearCache(PrincipalCollection principalCollection) {
        super.clearCache(principalCollection);
    }
}

3.6 LoginController.java

@RestController
@RequestMapping("")
public class LoginController {

    @PostMapping("/login")
    public ServerResponseVO login(@RequestParam(value = "account") String account,
                                  @RequestParam(value = "password") String password) {
        Subject userSubject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(account, password);
        try {
            // 登录验证
            userSubject.login(token);
            // 封装返回信息
            return ServerResponseVO.success(userSubject.getSession().getId());
        } catch (UnknownAccountException e) {
            return ServerResponseVO.error(ServerResponseEnum.ACCOUNT_NOT_EXIST);
        } catch (DisabledAccountException e) {
            return ServerResponseVO.error(ServerResponseEnum.ACCOUNT_IS_DISABLED);
        } catch (IncorrectCredentialsException e) {
            return ServerResponseVO.error(ServerResponseEnum.INCORRECT_CREDENTIALS);
        } catch (Throwable e) {
            e.printStackTrace();
            return ServerResponseVO.error(ServerResponseEnum.ERROR);
        }
    }
    
    @GetMapping("/login")
    public ServerResponseVO login() {
        return ServerResponseVO.error(ServerResponseEnum.NOT_LOGIN_IN);
    }

    @GetMapping("/auth")
    public String auth() {
        return "已成功登录";
    }

    @GetMapping("/role")
    @RequiresRoles("vip")
    public String role() {
        System.out.println("测试负载均衡效果");
        return "测试Vip角色";
    }

    @GetMapping("/permission")
    @RequiresPermissions(value = {"add", "update"}, logical = Logical.AND)
    public String permission() {
        return "测试Add和Update权限";
    }
}

经过上面的改动,已经可以实现分布式会话、缓存身份信息和缓存授权信息这些功能了,下面进入测试环节。

4. 测试效果

4.1 搭建集群

启动两个UserApplication,端口号分别为8903和8904

Nginx配置如下:

server {
        server_name   dev.ntshare.cn;
        
        location / {
            proxy_pass  http://load.ntshare.cn;
        }
    }

	upstream load.ntshare.cn {
    	server 127.0.0.1:8903 weight=1;
    	server 127.0.0.1:8904 weight=1;
    }

4.2 Postman访问测试

使用vip用户登录

vip用户登录.png

查看Redis情况

3.png

redis此时只有两个缓存,一个是session的缓存,一个是身份认证信息的缓存,并且身份认证缓存的key中使用了账号信息作为标识符

访问一个需要VIP角色的接口,注意添加Header头

访问角色接口.png

再看Redis中的缓存数量:

5.png

多了一个角色授权的缓存信息

使用Redis作为数据缓存后,系统只会在第一次身份认证和第一次角色授权时进行数据库查询,后面的操作都会走Redis缓存。

4.3 其他用户和接口测试

5. 总结

  1. 重写SessionManager,自定义获取sessionId的规则,并且前端将sessionId添加到请求头中,实现session的分布式会话。
  2. 通过集成Redis的方式,Shiro框架将认证信息和授权信息放入Redis中,避免了同一用户的多次认证和授权时重复查询数据库的问题。