整合SpringBoot+Shiro+Redis之完整案例,包括重写cache、cacheManager、SessionDAO

一个比较完整简单的ShiroDemo

一、创建springboot工程,导入pom文件依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.zjhc</groupId>
    <artifactId>shiro-all</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>war</packaging>
    <name>shiro-all</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
    <!-- 如果使用RedisProperties时需要引入 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- shiro -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.7.1</version>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>5.1.47</scope>
        </dependency>
        <!-- mybatis plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.2.0</version>
        </dependency>
        <!-- druid -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.23</version>
        </dependency>
        <!--hutool工具包-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.7</version>
        </dependency>
        <!-- thymeleaf 模板-->
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-java8time</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf</groupId>
            <artifactId>thymeleaf-spring5</artifactId>
        </dependency>
        <!--shiro标签+thymeleaf-->
        <dependency>
            <groupId>com.github.theborakompanioni</groupId>
            <artifactId>thymeleaf-extras-shiro</artifactId>
            <version>2.0.0</version>
        </dependency>
        <!-- shiro+redis 做session和缓存控制-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>3.3.1</version>
        </dependency>
        <!-- Hibernate Validate-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- shiro ehcahce缓存-->
        <!--<dependency>-->
        <!--    <groupId>org.apache.shiro</groupId>-->
        <!--    <artifactId>shiro-ehcache</artifactId>-->
        <!--    <version>1.7.1</version>-->
        <!--</dependency>-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

二、yml文件配置

server:
  port: 6677
spring:
  datasource:
    url: jdbc:mysql://12.41.106.140:43306/shiro_db?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=UTC
    username: root
    password: rnny@123456
    driver-class-name: com.mysql.cj.jdbc.Driver

  thymeleaf:
    cache: false

  redis:
    host: 12.41.106.140
    port: 16379
    password: rnny@123456
    jedis:
      pool:
        min-idle: 8
        max-wait: 10000
        max-active: 2000
        max-idle: 500

mybatis-plus:
  mapper-locations: classpath*:/mapper/**Mapper.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

三、根据MP插件生成mapper,entity,service,impl,mapper.xml

3.1 User类

1. User实体

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "user_info")
public class User implements Serializable {
    /**
     * 主键
     */
    @TableId(value = "uid", type = IdType.AUTO)
    private Integer uid;

    /**
     * 用户名
     */
    @TableField(value = "username")
    private String username;

    /**
     * 登录密码
     */
    @TableField(value = "password")
    private String password;

    /**
     * 用户真实姓名
     */
    @TableField(value = "name")
    private String name;

    /**
     * 手机号码
     */
    @TableField(value = "phone")
    private String phone;

    /**
     * 身份证号
     */
    @TableField(value = "idcard")
    private String idcard;

    /**
     * 用户状态;0:正常状态,1:账户被锁定
     */
    @TableField(value = "state")
    private String state;
}

2. UserService

public interface UserService extends IService<User>{
}

3. UserServiceImpl

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{
}

4. UserMapper

public interface UserMapper extends BaseMapper<User> {
    User getUserByUsername(String username);
    int insert(User user);
    int del(@Param("username")String username);
}

5. UserMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zjhc.mapper.UserMapper">
  <resultMap id="BaseResultMap" type="com.zjhc.entity.User">
    <!--@mbg.generated-->
    <!--@Table user_info-->
    <id column="uid" jdbcType="INTEGER" property="uid" />
    <result column="username" jdbcType="VARCHAR" property="username" />
    <result column="password" jdbcType="VARCHAR" property="password" />
    <result column="name" jdbcType="VARCHAR" property="name" />
    <result column="phone" jdbcType="VARCHAR" property="phone" />
    <result column="idcard" jdbcType="VARCHAR" property="idcard" />
    <result column="state" jdbcType="CHAR" property="state" />
  </resultMap>
  <sql id="Base_Column_List">
    <!--@mbg.generated-->
    `uid`, username, `password`, `name`, phone, idcard, `state`
  </sql>
    <select id="getUserByUsername" resultMap="BaseResultMap">
      select * from user_info where username = #{username}
    </select>

  <insert id="insert" parameterType="com.zjhc.entity.User">
    <selectKey resultType="java.lang.Integer" keyProperty="uid" order="AFTER">
        select last_insert_id()
    </selectKey>
    insert into user_info
    <trim prefix="(" suffix=")" suffixOverrides=",">
      <if test="uid != null">
        uid,
      </if>
      <if test="username != null and username != ''">
        username,
      </if>
      <if test="password != null and password != ''">
        password,
      </if>
      <if test="name != null and name != ''">
        name,
      </if>
      <if test="phone != null and phone != ''">
        phone,
      </if>
      <if test="idcard != null and idcard != ''">
        idcard,
      </if>
      <if test="state != null and state != ''">
        state
      </if>
    </trim>
    <trim prefix="values(" suffix=")" suffixOverrides=",">
      <if test="uid != null">
        #{uid},
      </if>
      <if test="username != null and username != ''">
        #{username},
      </if>
      <if test="password != null and password != ''">
        #{password},
      </if>
      <if test="name != null and name != ''">
        #{name},
      </if>
      <if test="phone != null and phone != ''">
        #{phone},
      </if>
      <if test="idcard != null and idcard != ''">
        #{idcard},
      </if>
      <if test="state != null and state != ''">
        #{state}
      </if>
    </trim>
  </insert>

  <delete id="del">
    delete from user_info where username=#{username}
  </delete>
</mapper>

3.2 Role类

1. Role实体

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_role")
public class Role {
    /**
     * 角色表主键
     */
    @TableId(value = "role_id", type = IdType.AUTO)
    private Integer roleId;

    /**
     * 角色名称
     */
    @TableField(value = "role_name")
    private String roleName;

    /**
     * 是否可用:0可用1不可用
     */
    @TableField(value = "avaliable")
    private String avaliable;

    /**
     * 角色描述
     */
    @TableField(value = "description")
    private String description;
}

2. RoleService

public interface RoleService extends IService<Role>{
}

3. RoleServiceImpl

@Service
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService{
}

4. RoleMapper

public interface RoleMapper extends BaseMapper<Role> {
    Set<Role> findRolesByUserId(@Param("uid") Integer uid);
    void delPermission(int i, int i1);
    void addPermission(int i, int i1);
}

5. RoleMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zjhc.mapper.RoleMapper">
  <resultMap id="BaseResultMap" type="com.zjhc.entity.Role">
    <!--@mbg.generated-->
    <!--@Table sys_role-->
    <id column="role_id" jdbcType="INTEGER" property="roleId" />
    <result column="role_name" jdbcType="VARCHAR" property="roleName" />
    <result column="avaliable" jdbcType="CHAR" property="avaliable" />
    <result column="description" jdbcType="VARCHAR" property="description" />
  </resultMap>
  <sql id="Base_Column_List">
    <!--@mbg.generated-->
    role_id, role_name, avaliable, description
  </sql>
    <select id="findRolesByUserId" resultMap="BaseResultMap" resultSets="java.util.Set" resultType="com.zjhc.entity.Role">
      SELECT r.* from sys_role r LEFT JOIN sys_user_role ur on r.role_id = ur.rid where ur.uid  = #{uid}
    </select>
    <delete id="delPermission">
    delete from sys_role_permission where role_id=1 and permission_id=3
    </delete>
  <insert id="addPermission">
    insert into sys_role_permission(role_id, permission_id) values(1,3)
  </insert>
</mapper>

3.3 Permissionlei

1. Permission实体

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_permission")
public class Permission {
    /**
     * 权限表主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
     * 父权限编号,本权限可能是该父编号权限的子权限
     */
    @TableField(value = "parent_id")
    private Integer parentId;

    /**
     * 父编号列表
     */
    @TableField(value = "parent_ids")
    private String parentIds;

    /**
     * 权限编码,menu例子:role:*,button例子:role:create,role:update,role:delete,role:view
     */
    @TableField(value = "permission_Code")
    private String permissionCode;

    /**
     * 权限名称
     */
    @TableField(value = "permission_Name")
    private String permissionName;

    /**
     * 资源类型,[menu|button]
     */
    @TableField(value = "resource_type")
    private String resourceType;

    /**
     * 资源路径 如:/userinfo/list
     */
    @TableField(value = "url")
    private String url;

    /**
     * 是否可用0可用  1不可用
     */
    @TableField(value = "avaliable")
    private String avaliable;
}

2.PermissionService

public interface PermissionService extends IService<Permission>{
}

3.PermissionServiceImpl

@Service
public class PermissionServiceImpl extends ServiceImpl<PermissionMapper, Permission> implements PermissionService{
}

4.PermissionMapper

public interface PermissionMapper extends BaseMapper<Permission> {
    Set<Permission> findPermissionsByRoleId(@Param("roles") Set<Role> roles);
}

5.PermissionMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zjhc.mapper.PermissionMapper">
  <resultMap id="BaseResultMap" type="com.zjhc.entity.Permission">
    <!--@mbg.generated-->
    <!--@Table sys_permission-->
    <id column="id" jdbcType="INTEGER" property="id" />
    <result column="parent_id" jdbcType="INTEGER" property="parentId" />
    <result column="parent_ids" jdbcType="VARCHAR" property="parentIds" />
    <result column="permission_Code" jdbcType="VARCHAR" property="permissionCode" />
    <result column="permission_Name" jdbcType="VARCHAR" property="permissionName" />
    <result column="resource_type" jdbcType="VARCHAR" property="resourceType" />
    <result column="url" jdbcType="VARCHAR" property="url" />
    <result column="avaliable" jdbcType="CHAR" property="avaliable" />
  </resultMap>
  <sql id="Base_Column_List">
    <!--@mbg.generated-->
    id, parent_id, parent_ids, permission_Code, permission_Name, resource_type, url, 
    avaliable
  </sql>
    <select id="findPermissionsByRoleId" resultMap="BaseResultMap" resultSets="java.util.Set" resultType="com.zjhc.entity.Permission">
      SELECT p.* from sys_permission p LEFT JOIN sys_role_permission rp on p.id = rp.permission_id WHERE rp.role_id IN
      <foreach collection="roles" index="index" item="item" open="(" close=")" separator=",">
        #{item.roleId}
      </foreach>
    </select>
</mapper>

四、创建UserRealm

4.1 UserRealm

1. UserRealm实现

@Slf4j
public class UserRealm extends AuthorizingRealm {

    @Lazy
    @Autowired
    UserMapper userMapper;

    @Lazy
    @Autowired
    RoleMapper roleMapper;

    @Lazy
    @Autowired
    PermissionMapper permissionMapper;

    public String getName(){
        return "UserRealm";
    }

    /**
     * 授权
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        log.info("=====================enter method UserRealm-doGetAuthorizationInfo获取角色权限");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        Subject subject = SecurityUtils.getSubject();
        //获取当前用户信息
        User user = (User) subject.getPrincipal();
        //User user = userMapper.getUserByUsername(username);
        //查询角色
        Set<Role> roles = this.roleMapper.findRolesByUserId(user.getUid());
        for(Role role : roles){
            info.addRole(role.getRoleName());
        }
        //查询权限
        Set<Permission> permissions = this.permissionMapper.findPermissionsByRoleId(roles);
        for(Permission permission : permissions){
            info.addStringPermission(permission.getPermissionCode());
        }
        return info;
    }

    /**
     * 认证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        log.info("=====================enter method UserRealm-doGetAuthenticationInfo进行登陆验证");
        UsernamePasswordToken userToken = (UsernamePasswordToken) authenticationToken;
        User user = userMapper.getUserByUsername(userToken.getUsername());
        if(null == user){
            throw new UnknownAccountException("账号不存在,请重试");
        }
        if("1".equals(user.getState())){
            throw new LockedAccountException("账户已被锁定");
        }
        return new SimpleAuthenticationInfo(user,user.getPassword(),new MyByteSource("!QAZ@WSX$RFV"),getName());
    }
  }

五、整合Redis重写Cache,CacheManager,SessionDAO

1. 整合Redis

redis客户端使用的是RedisTemplate,自己写了一个序列化工具继承RedisSerializer

1.1 SerializeUtils

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import java.io.*;
public class SerializeUtils implements RedisSerializer {
    private static Logger logger = LoggerFactory.getLogger(SerializeUtils.class);

    public static boolean isEmpty(byte[] data) {
        return (data == null || data.length == 0);
    }

    /**
     * 序列化
     * @param object
     * @return
     * @throws SerializationException
     */
    @Override
    public byte[] serialize(Object object) throws SerializationException {
        byte[] result = null;

        if (object == null) {
            return new byte[0];
        }
        try (
                ByteArrayOutputStream byteStream = new ByteArrayOutputStream(128);
                ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteStream)
        ){

            if (!(object instanceof Serializable)) {
                throw new IllegalArgumentException(SerializeUtils.class.getSimpleName() + " requires a Serializable payload " +
                        "but received an object of type [" + object.getClass().getName() + "]");
            }

            objectOutputStream.writeObject(object);
            objectOutputStream.flush();
            result =  byteStream.toByteArray();
        } catch (Exception ex) {
            logger.error("Failed to serialize",ex);
        }
        return result;
    }

    /**
     * 反序列化
     * @param bytes
     * @return
     * @throws SerializationException
     */
    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {

        Object result = null;

        if (isEmpty(bytes)) {
            return null;
        }
        try (
                ByteArrayInputStream byteStream = new ByteArrayInputStream(bytes);
                ObjectInputStream objectInputStream = new ObjectInputStream(byteStream)
        ){
            result = objectInputStream.readObject();
        } catch (Exception e) {
            logger.error("Failed to deserialize",e);
        }
        return result;
    }
}

1.2 RedisConfig

import com.zjhc.utils.SerializeUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import redis.clients.jedis.JedisPoolConfig;
@Configuration
public class RedisConfig {
    /**
     * redis地址
     */
    @Value("${spring.redis.host}")
    private String host;

    /**
     * redis端口号
     */
    @Value("${spring.redis.port}")
    private Integer port;

    /**
     * redis密码
     */
    @Value("${spring.redis.password}")
    private String password;


    /**
     * JedisPoolConfig 连接池
     * @return
     */
    @Bean
    public JedisPoolConfig jedisPoolConfig(){
        JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
        //最大空闲数
        jedisPoolConfig.setMaxIdle(300);
        //连接池的最大数据库连接数
        jedisPoolConfig.setMaxTotal(1000);
        //最大建立连接等待时间
        jedisPoolConfig.setMaxWaitMillis(1000);
        //逐出连接的最小空闲时间 默认1800000毫秒(30分钟)
        jedisPoolConfig.setMinEvictableIdleTimeMillis(300000);
        //每次逐出检查时 逐出的最大数目 如果为负数就是 : 1/abs(n), 默认3
        jedisPoolConfig.setNumTestsPerEvictionRun(10);
        //逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
        jedisPoolConfig.setTimeBetweenEvictionRunsMillis(30000);
        //是否在从池中取出连接前进行检验,如果检验失败,则从池中去除连接并尝试取出另一个
        jedisPoolConfig.setTestOnBorrow(true);
        //在空闲时检查有效性, 默认false
        jedisPoolConfig.setTestWhileIdle(true);
        return jedisPoolConfig;
    }

    /**
     * 配置工厂
     * @param jedisPoolConfig
     * @return
     */
    @Bean
    public JedisConnectionFactory jedisConnectionFactory(JedisPoolConfig jedisPoolConfig){
        JedisConnectionFactory jedisConnectionFactory=new JedisConnectionFactory();
        //连接池
        jedisConnectionFactory.setPoolConfig(jedisPoolConfig);
        //IP地址
        jedisConnectionFactory.setHostName(host);
        //端口号
        jedisConnectionFactory.setPort(port);
        //如果Redis设置有密码
        jedisConnectionFactory.setPassword(password);
        //客户端超时时间单位是毫秒
        jedisConnectionFactory.setTimeout(5000);
        return jedisConnectionFactory;
    }

    /**
     * shiro redis缓存使用的模板
     * 实例化 RedisTemplate 对象
     * @return
     */
    @Bean
    public RedisTemplate shiroRedisTemplate(RedisConnectionFactory redisConnectionFactory) {

        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new SerializeUtils());
        redisTemplate.setValueSerializer(new SerializeUtils());
        //开启事务
        //stringRedisTemplate.setEnableTransactionSupport(true);
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }
}

1.3 RedisManager

import org.apache.shiro.dao.DataAccessException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.util.CollectionUtils;

import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * @ClassName RedisManager
 * @Description TODO 基于spring和redis的redisTemplate工具类
 * @Version 1.0
 */
public class RedisManager {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    //=============================common============================
    /**
     * 指定缓存失效时间
     * @param key 键
     * @param time 时间(秒)
     */
    public void expire(String key,long time){
        redisTemplate.expire(key, time, TimeUnit.SECONDS);
    }

    /**
     * 判断key是否存在
     * @param key 键
     * @return true 存在 false不存在
     */
    public Boolean hasKey(String key){
        return redisTemplate.hasKey(key);
    }

    /**
     * 删除缓存
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public void del(String ... key){
        if(key!=null&&key.length>0){
            if(key.length==1){
                redisTemplate.delete(key[0]);
            }else{
                redisTemplate.delete((Collection<String>) CollectionUtils.arrayToList(key));
            }
        }
    }

    /**
     * 批量删除key
     * @param keys
     */
    public void del(Collection keys){
        redisTemplate.delete(keys);
    }

    //============================String=============================
    /**
     * 普通缓存获取
     * @param key 键
     * @return 值
     */
    public Object get(String key){
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 普通缓存放入
     * @param key 键
     * @param value 值
     */
    public void set(String key,Object value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 普通缓存放入并设置时间
     * @param key 键
     * @param value 值
     * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
     */
    public void set(String key,Object value,long time){
        if(time>0){
            redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
        }else{
            set(key, value);
        }
    }

    /**
     * 使用scan命令 查询某些前缀的key
     * @param key
     * @return
     */
    public Set<String> scan(String key){
        Set<String> execute = this.redisTemplate.execute(new RedisCallback<Set<String>>() {

            @Override
            public Set<String> doInRedis(RedisConnection connection) throws DataAccessException {

                Set<String> binaryKeys = new HashSet<>();

                Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder().match(key).count(1000).build());
                while (cursor.hasNext()) {
                    binaryKeys.add(new String(cursor.next()));
                }
                return binaryKeys;
            }
        });
        return execute;
    }

    /**
     * 使用scan命令 查询某些前缀的key 有多少个
     * 用来获取当前session数量,也就是在线用户
     * @param key
     * @return
     */
    public Long scanSize(String key){
        long dbSize = this.redisTemplate.execute(new RedisCallback<Long>() {

            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                long count = 0L;
                Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions().match(key).count(1000).build());
                while (cursor.hasNext()) {
                    cursor.next();
                    count++;
                }
                return count;
            }
        });
        return dbSize;
    }
}

2. 使用Redis作为缓存需要shiro重写cache、cacheManager、SessionDAO

2.1 重写RedisCache

import com.zjhc.redis.PrincipalException.PrincipalIdNullException;
import com.zjhc.redis.PrincipalException.PrincipalInstanceException;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;

/**
 * @ClassName RedisCache
 * @Description TODO
 */
public class RedisCache<K, V> implements Cache<K, V> {

    private static Logger logger = LoggerFactory.getLogger(RedisCache.class);

    private RedisManager redisManager;
    private String keyPrefix = "";
    private int expire = 0;
    private String principalIdFieldName = RedisCacheManager.DEFAULT_PRINCIPAL_ID_FIELD_NAME;

    /**
     * Construction
     * @param redisManager
     */
    public RedisCache(RedisManager redisManager, String prefix, int expire, String principalIdFieldName) {
        if (redisManager == null) {
            throw new IllegalArgumentException("redisManager cannot be null.");
        }
        this.redisManager = redisManager;
        if (prefix != null && !"".equals(prefix)) {
            this.keyPrefix = prefix;
        }
        if (expire != -1) {
            this.expire = expire;
        }
        if (principalIdFieldName != null && !"".equals(principalIdFieldName)) {
            this.principalIdFieldName = principalIdFieldName;
        }
    }

    @Override
    public V get(K key) throws CacheException {
        logger.debug("get key [{}]",key);

        if (key == null) {
            return null;
        }

        try {
            String redisCacheKey = getRedisCacheKey(key);
            Object rawValue = redisManager.get(redisCacheKey);
            if (rawValue == null) {
                return null;
            }
            V value = (V) rawValue;
            return value;
        } catch (Exception e) {
            throw new CacheException(e);
        }
    }

    @Override
    public V put(K key, V value) throws CacheException {
        logger.debug("put key [{}]",key);
        if (key == null) {
            logger.warn("Saving a null key is meaningless, return value directly without call Redis.");
            return value;
        }
        try {
            String redisCacheKey = getRedisCacheKey(key);
            redisManager.set(redisCacheKey, value, expire);
            return value;
        } catch (Exception e) {
            throw new CacheException(e);
        }
    }

    @Override
    public V remove(K key) throws CacheException {
        logger.debug("remove key [{}]",key);
        if (key == null) {
            return null;
        }
        try {
            String redisCacheKey = getRedisCacheKey(key);
            Object rawValue = redisManager.get(redisCacheKey);
            V previous = (V) rawValue;
            redisManager.del(redisCacheKey);
            return previous;
        } catch (Exception e) {
            throw new CacheException(e);
        }
    }

    private String getRedisCacheKey(K key) {
        if (key == null) {
            return null;
        }
        return this.keyPrefix + getStringRedisKey(key);
    }

    private String getStringRedisKey(K key) {
        String redisKey;
        if (key instanceof PrincipalCollection) {
            redisKey = getRedisKeyFromPrincipalIdField((PrincipalCollection) key);
        } else {
            redisKey = key.toString();
        }
        return redisKey;
    }

    private String getRedisKeyFromPrincipalIdField(PrincipalCollection key) {
        String redisKey;
        Object principalObject = key.getPrimaryPrincipal();
        Method pincipalIdGetter = null;
        Method[] methods = principalObject.getClass().getDeclaredMethods();
        for (Method m:methods) {
            if (RedisCacheManager.DEFAULT_PRINCIPAL_ID_FIELD_NAME.equals(this.principalIdFieldName)
                    && ("getAuthCacheKey".equals(m.getName()) || "getId".equals(m.getName()))) {
                pincipalIdGetter = m;
                break;
            }
            if (m.getName().equals("get" + this.principalIdFieldName.substring(0, 1).toUpperCase() + this.principalIdFieldName.substring(1))) {
                pincipalIdGetter = m;
                break;
            }
        }
        if (pincipalIdGetter == null) {
            throw new PrincipalInstanceException(principalObject.getClass(), this.principalIdFieldName);
        }

        try {
            Object idObj = pincipalIdGetter.invoke(principalObject);
            if (idObj == null) {
                throw new PrincipalIdNullException(principalObject.getClass(), this.principalIdFieldName);
            }
            redisKey = idObj.toString();
        } catch (IllegalAccessException e) {
            throw new PrincipalInstanceException(principalObject.getClass(), this.principalIdFieldName, e);
        } catch (InvocationTargetException e) {
            throw new PrincipalInstanceException(principalObject.getClass(), this.principalIdFieldName, e);
        }

        return redisKey;
    }


    @Override
    public void clear() throws CacheException {
        logger.debug("clear cache");
        Set<String> keys = null;
        try {
            keys = redisManager.scan(this.keyPrefix + "*");
        } catch (Exception e) {
            logger.error("get keys error", e);
        }
        if (keys == null || keys.size() == 0) {
            return;
        }
        for (String key: keys) {
            redisManager.del(key);
        }
    }

    @Override
    public int size() {
        Long longSize = 0L;
        try {
            longSize = new Long(redisManager.scanSize(this.keyPrefix + "*"));
        } catch (Exception e) {
            logger.error("get keys error", e);
        }
        return longSize.intValue();
    }

    @SuppressWarnings("unchecked")
    @Override
    public Set<K> keys() {
        Set<String> keys = null;
        try {
            keys = redisManager.scan(this.keyPrefix + "*");
        } catch (Exception e) {
            logger.error("get keys error", e);
            return Collections.emptySet();
        }

        if (CollectionUtils.isEmpty(keys)) {
            return Collections.emptySet();
        }

        Set<K> convertedKeys = new HashSet<K>();
        for (String key:keys) {
            try {
                convertedKeys.add((K) key);
            } catch (Exception e) {
                logger.error("deserialize keys error", e);
            }
        }
        return convertedKeys;
    }

    @Override
    public Collection<V> values() {
        Set<String> keys = null;
        try {
            keys = redisManager.scan(this.keyPrefix + "*");
        } catch (Exception e) {
            logger.error("get values error", e);
            return Collections.emptySet();
        }

        if (CollectionUtils.isEmpty(keys)) {
            return Collections.emptySet();
        }

        List<V> values = new ArrayList<V>(keys.size());
        for (String key : keys) {
            V value = null;
            try {
                value = (V) redisManager.get(key);
            } catch (Exception e) {
                logger.error("deserialize values= error", e);
            }
            if (value != null) {
                values.add(value);
            }
        }
        return Collections.unmodifiableList(values);
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    public String getPrincipalIdFieldName() {
        return principalIdFieldName;
    }

    public void setPrincipalIdFieldName(String principalIdFieldName) {
        this.principalIdFieldName = principalIdFieldName;
    }
}
2.1.1 读取用户权限信息时,还用到两个异常类
2.1.1.1 PrincipalInstanceException
public class PrincipalInstanceException extends RuntimeException {
    private static final long serialVersionUID = 5011741906229816259L;

    private static final String MESSAGE = "We need a field to identify this Cache Object in Redis. "
            + "So you need to defined an id field which you can get unique id to identify this principal. "
            + "For example, if you use UserInfo as Principal class, the id field maybe userId, userName, email, etc. "
            + "For example, getUserId(), getUserName(), getEmail(), etc.\n"
            + "Default value is authCacheKey or id, that means your principal object has a method called \"getAuthCacheKey()\" or \"getId()\"";

    public PrincipalInstanceException(Class clazz, String idMethodName) {
        super(clazz + " must has getter for field: " +  idMethodName + "\n" + MESSAGE);
    }

    public PrincipalInstanceException(Class clazz, String idMethodName, Exception e) {
        super(clazz + " must has getter for field: " +  idMethodName + "\n" + MESSAGE, e);
    }
2.1.1.2 PrincipalIdNullException
public class PrincipalIdNullException extends RuntimeException {
    private static final long serialVersionUID = -3311055610511133072L;

    private static final String MESSAGE = "Principal Id shouldn't be null!";

    public PrincipalIdNullException(Class clazz, String idMethodName) {
        super(clazz + " id field: " +  idMethodName + ", value is null\n" + MESSAGE);
    }
}

2.2 重写RedisCacheManager

import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.cache.CacheManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 * @ClassName RedisCacheManager
 * @Description TODO
 */
public class RedisCacheManager implements CacheManager {
    private final Logger logger = LoggerFactory.getLogger(RedisCacheManager.class);

    /**
     * fast lookup by name map
     */
    private final ConcurrentMap<String, Cache> caches = new ConcurrentHashMap<String, Cache>();

    private RedisManager redisManager;

    /**
     * expire time in seconds
     */
    private static final int DEFAULT_EXPIRE = 1800;
    private int expire = DEFAULT_EXPIRE;

    /**
     * The Redis key prefix for caches
     */
    public static final String DEFAULT_CACHE_KEY_PREFIX = "shiro:cache:";
    private String keyPrefix = DEFAULT_CACHE_KEY_PREFIX;

    public static final String DEFAULT_PRINCIPAL_ID_FIELD_NAME = "authCacheKey or id";
    private String principalIdFieldName = DEFAULT_PRINCIPAL_ID_FIELD_NAME;

    @Override
    public <K, V> Cache<K, V> getCache(String name) throws CacheException {
        logger.debug("get cache, name={}",name);

        Cache cache = caches.get(name);

        if (cache == null) {
            cache = new RedisCache<K, V>(redisManager,keyPrefix + name + ":", expire, principalIdFieldName);
            caches.put(name, cache);
        }
        return cache;
    }

    public RedisManager getRedisManager() {
        return redisManager;
    }

    public void setRedisManager(RedisManager redisManager) {
        this.redisManager = redisManager;
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    public int getExpire() {
        return expire;
    }

    public void setExpire(int expire) {
        this.expire = expire;
    }

    public String getPrincipalIdFieldName() {
        return principalIdFieldName;
    }

    public void setPrincipalIdFieldName(String principalIdFieldName) {
        this.principalIdFieldName = principalIdFieldName;
    }
}

2.3 重写RedisSessionDAO

import com.zjhc.redis.cache.RedisManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.ValidatingSession;
import org.apache.shiro.session.mgt.eis.AbstractSessionDAO;
import org.crazycake.shiro.common.SessionInMemory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.*;

/**
 * @ClassName RedisSessionDao
 * @Description TODO
 */
public class RedisSessionDAO extends AbstractSessionDAO {
    private static Logger logger = LoggerFactory.getLogger(RedisSessionDAO.class);

    private static final String DEFAULT_SESSION_KEY_PREFIX = "shiro:session:";
    private String keyPrefix = DEFAULT_SESSION_KEY_PREFIX;

    private static final long DEFAULT_SESSION_IN_MEMORY_TIMEOUT = 1000L;
    /**
     * doReadSession be called about 10 times when login.
     * Save Session in ThreadLocal to resolve this problem. sessionInMemoryTimeout is expiration of Session in ThreadLocal.
     * The default value is 1000 milliseconds (1s).
     * Most of time, you don't need to change it.
     */
    private long sessionInMemoryTimeout = DEFAULT_SESSION_IN_MEMORY_TIMEOUT;

    /**
     * expire time in seconds
     */
    private static final int DEFAULT_EXPIRE = -2;
    private static final int NO_EXPIRE = -1;

    /**
     * Please make sure expire is longer than sesion.getTimeout()
     */
    private int expire = DEFAULT_EXPIRE;

    private static final int MILLISECONDS_IN_A_SECOND = 1000;

    private RedisManager redisManager;
    private static ThreadLocal sessionsInThread = new ThreadLocal();

    @Override
    public void update(Session session) throws UnknownSessionException {
        //如果会话过期/停止 没必要再更新了
        try {
            if (session instanceof ValidatingSession && !((ValidatingSession) session).isValid()) {
                return;
            }

            if (session instanceof ShiroSession) {
                // 如果没有主要字段(除lastAccessTime以外其他字段)发生改变
                ShiroSession ss = (ShiroSession) session;
                if (!ss.isChanged()) {
                    return;
                }
                //如果没有返回 证明有调用 setAttribute往redis 放的时候永远设置为false
                ss.setChanged(false);
            }

            this.saveSession(session);
        } catch (Exception e) {
            logger.warn("update Session is failed", e);
        }
    }

    /**
     * save session
     * @param session
     * @throws UnknownSessionException
     */
    private void saveSession(Session session) throws UnknownSessionException {
        if (session == null || session.getId() == null) {
            logger.error("session or session id is null");
            throw new UnknownSessionException("session or session id is null");
        }
        String key = getRedisSessionKey(session.getId());
        if (expire == DEFAULT_EXPIRE) {
            this.redisManager.set(key, session, (int) (session.getTimeout() / MILLISECONDS_IN_A_SECOND));
            return;
        }
        if (expire != NO_EXPIRE && expire * MILLISECONDS_IN_A_SECOND < session.getTimeout()) {
            logger.warn("Redis session expire time: "
                    + (expire * MILLISECONDS_IN_A_SECOND)
                    + " is less than Session timeout: "
                    + session.getTimeout()
                    + " . It may cause some problems.");
        }
        this.redisManager.set(key, session, expire);
    }

    @Override
    public void delete(Session session) {
        if (session == null || session.getId() == null) {
            logger.error("session or session id is null");
            return;
        }
        try {
            redisManager.del(getRedisSessionKey(session.getId()));
        } catch (Exception e) {
            logger.error("delete session error. session id= {}",session.getId());
        }
    }

    @Override
    public Collection<Session> getActiveSessions() {
        Set<Session> sessions = new HashSet<Session>();
        try {
            Set<String> keys = redisManager.scan(this.keyPrefix + "*");
            if (keys != null && keys.size() > 0) {
                for (String key:keys) {
                    Session s = (Session) redisManager.get(key);
                    sessions.add(s);
                }
            }
        } catch (Exception e) {
            logger.error("get active sessions error.");
        }
        return sessions;
    }

    public Long getActiveSessionsSize() {
        Long size = 0L;
        try {
            size = redisManager.scanSize(this.keyPrefix + "*");
        } catch (Exception e) {
            logger.error("get active sessions error.");
        }
        return size;
    }

    @Override
    protected Serializable doCreate(Session session) {
        if (session == null) {
            logger.error("session is null");
            throw new UnknownSessionException("session is null");
        }
        Serializable sessionId = this.generateSessionId(session);
        this.assignSessionId(session, sessionId);
        this.saveSession(session);
        return sessionId;
    }

    @Override
    protected Session doReadSession(Serializable sessionId) {
        if (sessionId == null) {
            logger.warn("session id is null");
            return null;
        }
        Session s = getSessionFromThreadLocal(sessionId);

        if (s != null) {
            return s;
        }

        logger.debug("read session from redis");
        try {
            s = (Session) redisManager.get(getRedisSessionKey(sessionId));
            setSessionToThreadLocal(sessionId, s);
        } catch (Exception e) {
            logger.error("read session error. settionId= {}",sessionId);
        }
        return s;
    }

    private void setSessionToThreadLocal(Serializable sessionId, Session s) {
        Map<Serializable, SessionInMemory> sessionMap = (Map<Serializable, SessionInMemory>) sessionsInThread.get();
        if (sessionMap == null) {
            sessionMap = new HashMap<Serializable, SessionInMemory>();
            sessionsInThread.set(sessionMap);
        }
        SessionInMemory sessionInMemory = new SessionInMemory();
        sessionInMemory.setCreateTime(new Date());
        sessionInMemory.setSession(s);
        sessionMap.put(sessionId, sessionInMemory);
    }

    private Session getSessionFromThreadLocal(Serializable sessionId) {
        Session s = null;

        if (sessionsInThread.get() == null) {
            return null;
        }

        Map<Serializable, SessionInMemory> sessionMap = (Map<Serializable, SessionInMemory>) sessionsInThread.get();
        SessionInMemory sessionInMemory = sessionMap.get(sessionId);
        if (sessionInMemory == null) {
            return null;
        }
        Date now = new Date();
        long duration = now.getTime() - sessionInMemory.getCreateTime().getTime();
        if (duration < sessionInMemoryTimeout) {
            s = sessionInMemory.getSession();
            logger.debug("read session from memory");
        } else {
            sessionMap.remove(sessionId);
        }

        return s;
    }

    private String getRedisSessionKey(Serializable sessionId) {
        return this.keyPrefix + sessionId;
    }

    public RedisManager getRedisManager() {
        return redisManager;
    }

    public void setRedisManager(RedisManager redisManager) {
        this.redisManager = redisManager;
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    public long getSessionInMemoryTimeout() {
        return sessionInMemoryTimeout;
    }

    public void setSessionInMemoryTimeout(long sessionInMemoryTimeout) {
        this.sessionInMemoryTimeout = sessionInMemoryTimeout;
    }

    public int getExpire() {
        return expire;
    }

    public void setExpire(int expire) {
        this.expire = expire;
    }
}

3. 为了解决Shiro频繁访问Redis读取和更新session

3.1解决单次请求需要多次访问redis,重写DefaultWebSessionManager 的 retrieveSession()方法

3.1.1 重写SessionManager
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.SessionKey;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.session.mgt.WebSessionKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletRequest;
import java.io.Serializable;

/**
 * @ClassName ShiroSessionManager
 * @Description TODO 解决单次请求需要多次访问redis
 */
public class ShiroSessionManager extends DefaultWebSessionManager {
    private static Logger logger = LoggerFactory.getLogger(DefaultWebSessionManager.class);
    /**
     * 获取session
     * 优化单次请求需要多次访问redis的问题
     * @param sessionKey
     * @return
     * @throws UnknownSessionException
     */
    @Override
    protected Session retrieveSession(SessionKey sessionKey) throws UnknownSessionException {
        Serializable sessionId = getSessionId(sessionKey);

        ServletRequest request = null;
        if (sessionKey instanceof WebSessionKey) {
            request = ((WebSessionKey) sessionKey).getServletRequest();
        }

        if (request != null && null != sessionId) {
            Object sessionObj = request.getAttribute(sessionId.toString());
            if (sessionObj != null) {
                logger.debug("read session from request");
                return (Session) sessionObj;
            }
        }

        Session session = super.retrieveSession(sessionKey);
        if (request != null && null != sessionId) {
            request.setAttribute(sessionId.toString(), session);
        }
        return session;
    }
}

3.2 频繁更新session解决方案

由于SimpleSession lastAccessTime更改后也会调用SessionDao update方法,更新的字段只有LastAccessTime(最后一次访问时间),由于会话失效是由Redis数据过期实现的,这个字段意义不大,为了减少对Redis的访问,降低网络压力,实现自己的Session,在SimpleSession上套一层,增加一个标识位,如果Session除lastAccessTime意外其它字段修改,就标识一下,只有标识为修改的才可以通过doUpdate访问Redis,否则直接返回

3.2.1 创建ShiroSession
public class ShiroSession extends SimpleSession implements Serializable {
    private static final long serialVersionUID = -9002259560628972558L;
    // 除lastAccessTime以外其他字段发生改变时为true
    private boolean isChanged = false;

    public ShiroSession() {
        super();
        this.setChanged(true);
    }

    public ShiroSession(String host) {
        super(host);
        this.setChanged(true);
    }


    @Override
    public void setId(Serializable id) {
        super.setId(id);
        this.setChanged(true);
    }

    @Override
    public void setStopTimestamp(Date stopTimestamp) {
        super.setStopTimestamp(stopTimestamp);
        this.setChanged(true);
    }

    @Override
    public void setExpired(boolean expired) {
        super.setExpired(expired);
        this.setChanged(true);
    }

    @Override
    public void setTimeout(long timeout) {
        super.setTimeout(timeout);
        this.setChanged(true);
    }

    @Override
    public void setHost(String host) {
        super.setHost(host);
        this.setChanged(true);
    }

    @Override
    public void setAttributes(Map<Object, Object> attributes) {
        super.setAttributes(attributes);
        this.setChanged(true);
    }

    @Override
    public void setAttribute(Object key, Object value) {
        super.setAttribute(key, value);
        this.setChanged(true);
    }

    @Override
    public Object removeAttribute(Object key) {
        this.setChanged(true);
        return super.removeAttribute(key);
    }

    /**
     * 停止
     */
    @Override
    public void stop() {
        super.stop();
        this.setChanged(true);
    }

    /**
     * 设置过期
     */
    @Override
    protected void expire() {
        this.stop();
        this.setExpired(true);
    }

    public boolean isChanged() {
        return isChanged;
    }

    public void setChanged(boolean isChanged) {
        this.isChanged = isChanged;
    }

    @Override
    public boolean equals(Object obj) {
        return super.equals(obj);
    }

    @Override
    protected boolean onEquals(SimpleSession ss) {
        return super.onEquals(ss);
    }

    @Override
    public int hashCode() {
        return super.hashCode();
    }

    @Override
    public String toString() {
        return super.toString();
    }
}
3.2.2 创建ShiroSessionFactory
public class ShiroSessionFactory implements SessionFactory {
    private static final Logger logger = LoggerFactory.getLogger(ShiroSessionFactory.class);

    @Override
    public Session createSession(SessionContext initData) {
        ShiroSession session = new ShiroSession();
        HttpServletRequest request = (HttpServletRequest)initData.get(DefaultWebSessionContext.class.getName() + ".SERVLET_REQUEST");
        session.setHost(getIpAddress(request));
        return session;
    }

    public static String getIpAddress(HttpServletRequest request) {
        String localIP = "127.0.0.1";
        String ip = request.getHeader("x-forwarded-for");
        if (StrUtil.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (StrUtil.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (StrUtil.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}
3.2.2.1 ShiroSession创建之后

修改RedisSessionDAO的update方法,判断如果只是更改session的lastAccessTime,则直接返回。

  @Override
    public void update(Session session) throws UnknownSessionException {
        //如果会话过期/停止 没必要再更新了
        try {
            if (session instanceof ValidatingSession && !((ValidatingSession) session).isValid()) {
                return;
            }
            if (session instanceof ShiroSession) {
                // 如果没有主要字段(除lastAccessTime以外其他字段)发生改变
                ShiroSession ss = (ShiroSession) session;
                if (!ss.isChanged()) {
                    return;
                }
                //如果没有返回 证明有调用 setAttribute往redis 放的时候永远设置为false
                ss.setChanged(false);
            }
            this.saveSession(session);
        } catch (Exception e) {
            logger.warn("update Session is failed", e);
        }
    }

六、密码加盐加密且登录次数限制

6.1 自定义密码匹配器,如果不需要做登录次数限制功能,则不需要自定义匹配器

如果用户输入密码连续错误5次,将锁定账号。使用该功能时
将shiroConfig中的值改为 shiroFilterFactoryBean.setLoginUrl("/");

import com.zjhc.entity.User;
import com.zjhc.mapper.UserMapper;
import com.zjhc.redis.cache.RedisManager;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @ClassName RetryLimitHashedCredentialsMatcher
 * @Description TODO  登陆次数限制
 */
@Slf4j
public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher {

    private static final Logger logger = LoggerFactory.getLogger(RetryLimitHashedCredentialsMatcher.class);

    public static final String DEFAULT_RETRYLIMIT_CACHE_KEY_PREFIX = "shiro:cache:retrylimit:";
    private String keyPrefix = DEFAULT_RETRYLIMIT_CACHE_KEY_PREFIX;
    @Autowired
    private UserMapper userMapper;
    private RedisManager redisManager;

    public void setRedisManager(RedisManager redisManager) {
        this.redisManager = redisManager;
    }

    private String getRedisKickoutKey(String username) {
        return this.keyPrefix + username;
    }

    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {

        //获取用户名
        String username = (String)token.getPrincipal();
        //获取用户登录次数
        AtomicInteger retryCount = (AtomicInteger)redisManager.get(getRedisKickoutKey(username));
        if (retryCount == null) {
            //如果用户没有登陆过,登陆次数加1 并放入缓存
            retryCount = new AtomicInteger(0);
        }
        if (retryCount.incrementAndGet() > 5) {
            //如果用户登陆失败次数大于5次 抛出锁定用户异常  并修改数据库字段
            User user = userMapper.getUserByUsername(username);
            if (user != null && "0".equals(user.getState())){
                //数据库字段 默认为 0  就是正常状态 所以 要改为1
                //修改数据库的状态字段为锁定
                user.setState("1");
                userMapper.updateById(user);
            }
            logger.info("锁定用户" + user.getUsername());
            //抛出用户锁定异常
            throw new LockedAccountException();
        }
        //判断用户账号和密码是否正确
        boolean matches = super.doCredentialsMatch(token, info);
        if (matches) {
            //如果正确,从缓存中将用户登录计数 清除
            redisManager.del(getRedisKickoutKey(username));
        }{
            redisManager.set(getRedisKickoutKey(username), retryCount);
        }
        return matches;
    }

    /**
     * 根据用户名 解锁用户
     * @param username
     * @return
     */
    public void unlockAccount(String username){
        User user = userMapper.getUserByUsername(username);
        if (user != null){
            //修改数据库的状态字段为锁定
            user.setState("0");
            userMapper.updateById(user);
            redisManager.del(getRedisKickoutKey(username));
        }
    }
}

6.2 生成密码加密加盐的方法

public class PasswordEncoder {

    public static String encoder(String password){

        SimpleHash simpleHash = new SimpleHash(
                "MD5",
                ByteSource.Util.bytes(password),
                ByteSource.Util.bytes("!QAZ@WSX$RFV"),
                3);
        return simpleHash.toString();
    }
    public static void main(String[] args) {

        String encoder = encoder("123456");
        System.out.println(encoder);
    }
}

6.3 shiro使用密码加盐之后,序列化和反序列化失败

6.3.1 取消authenticationCache

ShiroConfig的shiroRealm配置中我们开启了两个缓存:authenticationCache 和 authorizationCache 。序列化失败的原因就是开启了 authenticationCache ,将 authenticationCache对应的那两行配置删除,只缓存authorizationCache

6.3.2 自定义ByteSource的实现类

将SimpleByteSource整个类复制粘贴重新实现Serializable接口,并添加无参构造器

public class MyByteSource implements ByteSource, Serializable {
    private byte[] bytes;
    private String cachedHex;
    private String cachedBase64;

    public MyByteSource() {
    }
    public MyByteSource(byte[] bytes) {
        this.bytes = bytes;
    }
    public MyByteSource(char[] chars) {
        this.bytes = CodecSupport.toBytes(chars);
    }
    public MyByteSource(String string) {
        this.bytes = CodecSupport.toBytes(string);
    }
    public MyByteSource(ByteSource source) {
        this.bytes = source.getBytes();
    }
    public MyByteSource(File file) {
        this.bytes = new MyByteSource.BytesHelper().getBytes(file);
    }


    public MyByteSource(InputStream stream) {
        this.bytes = new MyByteSource.BytesHelper().getBytes(stream);
    }
    public static boolean isCompatible(Object o) {
        return o instanceof byte[] || o instanceof char[] || o instanceof String ||
                o instanceof ByteSource || o instanceof File || o instanceof InputStream;
    }

    @Override
    public byte[] getBytes() {
        return this.bytes;
    }
    @Override
    public boolean isEmpty() {
        return this.bytes == null || this.bytes.length == 0;
    }
    @Override
    public String toHex() {
        if ( this.cachedHex == null ) {
            this.cachedHex = Hex.encodeToString(getBytes());
        }
        return this.cachedHex;
    }
    @Override
    public String toBase64() {
        if ( this.cachedBase64 == null ) {
            this.cachedBase64 = Base64.encodeToString(getBytes());
        }
        return this.cachedBase64;
    }
    @Override
    public String toString() {
        return toBase64();
    }
    @Override
    public int hashCode() {
        if (this.bytes == null || this.bytes.length == 0) {
            return 0;
        }
        return Arrays.hashCode(this.bytes);
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (o instanceof ByteSource) {
            ByteSource bs = (ByteSource) o;
            return Arrays.equals(getBytes(), bs.getBytes());
        }
        return false;
    }
    private static final class BytesHelper extends CodecSupport {
        /**
         * 嵌套类也需要提供无参构造器
         */
        private BytesHelper() {
        }
        public byte[] getBytes(File file) {
            return toBytes(file);
        }
        public byte[] getBytes(InputStream stream) {
            return toBytes(stream);
        }
    }
}

6.4 修改UserRealm中doGetAuthenticationInfo

SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user,user.getPassword(),new MyByteSource("!QAZ@WSX$RFV"),getName());;

七、并发登录人数控制

注意:我们首先看一下 isAccessAllowed() 方法,在这个方法中,如果返回 true,则表示“通过”,走到下一个过滤器。如果没有下一个过滤器的话,表示具有了访问某个资源的权限。如果返回 false,则会调用 onAccessDenied 方法,去实现相应的当过滤不通过的时候执行的操作,例如检查用户是否已经登陆过,如果登陆过,根据自定义规则选择踢出前一个用户 还是 后一个用户。
onAccessDenied方法 返回 true 表示 自己处理完成,然后继续拦截器链执行。
只有当两者都返回false时,才会终止后面的filter执行

import com.zjhc.entity.User;
import com.zjhc.redis.cache.RedisManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.servlet.resource.ResourceUrlProvider;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.Serializable;
import java.util.Deque;
import java.util.LinkedList;

/**
 * @ClassName KickoutSessionControlFilter
 * @Description TODO  自定义filter 实现 并发登录控制
 * @Author zkc
 * @Date 2021/9/11 15:34
 * @Version 1.0
 */
public class KickoutSessionControlFilter extends AccessControlFilter {

    //@Autowired
    //private ResourceUrlProvider resourceUrlProvider;

    /** 踢出后到的地址 */
    private String kickoutUrl;

    /**  踢出之前登录的/之后登录的用户 默认踢出之前登录的用户 */
    private boolean kickoutAfter = false;

    /**  同一个帐号最大会话数 默认1 */
    private int maxSession = 1;
    private SessionManager sessionManager;

    private RedisManager redisManager;

    public static final String DEFAULT_KICKOUT_CACHE_KEY_PREFIX = "shiro:cache:kickout:";
    private String keyPrefix = DEFAULT_KICKOUT_CACHE_KEY_PREFIX;

    public void setKickoutUrl(String kickoutUrl) {
        this.kickoutUrl = kickoutUrl;
    }

    public void setKickoutAfter(boolean kickoutAfter) {
        this.kickoutAfter = kickoutAfter;
    }

    public void setMaxSession(int maxSession) {
        this.maxSession = maxSession;
    }

    public void setSessionManager(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    public void setRedisManager(RedisManager redisManager) {
        this.redisManager = redisManager;
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    private String getRedisKickoutKey(String username) {
        return this.keyPrefix + username;
    }


    /**
     * 是否允许访问,返回true表示允许,走到下一个过滤器。如果没有下一个过滤器的话,表示具有了访问某个资源的权限
     * 返回false,则会调用 onAccessDenied 方法,去实现相应的当过滤不通过的时候执行的操作
     * 例如检查用户是否已经登陆过,如果登陆过,根据自定义规则选择踢出前一个用户 还是 后一个用户。
     * onAccessDenied方法 返回 true 表示 自己处理完成,然后继续拦截器链执行。
     * 只有当两者都返回false时,才会终止后面的filter执行
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
        return false;
    }

    /**
     * 表示访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,
     * 返回false表示自己已经处理了(比如重定向到另一个页面)。
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        Subject subject = getSubject(servletRequest, servletResponse);

        if(!subject.isAuthenticated() && !subject.isRemembered()){
            //如果没有登录,直接进行之后的流程
            return true;
        }

        如果有登录,判断是否访问的为静态资源,如果是游客允许访问的静态资源,直接返回true
        //HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
        //String path = httpServletRequest.getServletPath();
         如果是静态文件,则返回true
        //if (isStaticFile(path)){
        //    return true;
        //}

        Session session = subject.getSession();
        //这里获取的User是实体 因为我在 自定义ShiroRealm中的doGetAuthenticationInfo方法中
        //new SimpleAuthenticationInfo(user, password, getName()); 传的是 User实体 所以这里拿到的也是实体,如果传的是userName
        // 这里拿到的就是userName
        String username = ((User)subject.getPrincipal()).getUsername();
        Serializable sessionId = session.getId();

        // 初始化用户的队列放到缓存里
        // 初始化用户的队列放到缓存里
        Deque<Serializable> deque = (Deque<Serializable>) redisManager.get(getRedisKickoutKey(username));
        if(deque == null || deque.size()==0) {
            deque = new LinkedList<Serializable>();
        }

        //如果队列里没有此sessionId,且用户没有被踢出;放入队列
        if(!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
            deque.push(sessionId);
        }


        //如果队列里的sessionId数超出最大会话数,开始踢人
        while(deque.size() > maxSession) {
            Serializable kickoutSessionId = null;
            if(kickoutAfter) { //如果踢出后者
                kickoutSessionId=deque.getFirst();
                kickoutSessionId = deque.removeFirst();
            } else { //否则踢出前者
                kickoutSessionId = deque.removeLast();
            }
            try {
                Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
                if(kickoutSession != null) {
                    //设置会话的kickout属性表示踢出了
                    kickoutSession.setAttribute("kickout", true);
                }
            } catch (Exception e) {//ignore exception
                e.printStackTrace();
            }
        }

        redisManager.set(getRedisKickoutKey(username), deque);

        //如果被踢出了,直接退出,重定向到踢出后的地址
        if (session.getAttribute("kickout") != null) {
            //会话被踢出了
            try {
                subject.logout();
            } catch (Exception e) {
            }
            WebUtils.issueRedirect(servletRequest, servletResponse, kickoutUrl);
            return false;
        }
        return true;
    }

    //private boolean isStaticFile(String path) {
    //    String staticUri = resourceUrlProvider.getForLookupPath(path);
    //    return staticUri != null;
    //}
}

在shiroconfig中配置
filterMap.put("/**", “kickout,user”); 表示 访问/**下的资源 首先要通过 kickout 后面的filter,然后再通过user后面对应的filter才可以访问;

 /**
     * 并发登录控制
     * @return
     */
    @Bean
    public KickoutSessionControlFilter kickoutSessionControlFilter(){
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        //用于根据会话ID,获取会话进行踢出操作的;
        kickoutSessionControlFilter.setSessionManager(sessionManager());
        //使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
        kickoutSessionControlFilter.setRedisManager(redisManager());
        //是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
        kickoutSessionControlFilter.setKickoutAfter(false);
        //同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
        kickoutSessionControlFilter.setMaxSession(1);
        //被踢出后重定向到的地址;
        kickoutSessionControlFilter.setKickoutUrl("/login?kickout=1");
        return kickoutSessionControlFilter;
    }
@Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        filterFactoryBean.setSecurityManager(securityManager);

        //自定义拦截器限制并发人数
        LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>();
        //限制同一帐号同时在线的个数
        filtersMap.put("kickout", kickoutSessionControlFilter());
        filterFactoryBean.setFilters(filtersMap);

        Map<String,String> filterMap = new LinkedHashMap<>();
        filterFactoryBean.setLoginUrl("/");
        filterFactoryBean.setUnauthorizedUrl("/unauthorized");
        filterMap.put("/logout","logout");
        //过滤
        filterMap.put("/login","anon");
        filterMap.put("/", "anon");
        filterMap.put("/css/**", "anon");
        filterMap.put("/js/**", "anon");
        filterMap.put("/img/**", "anon");
        filterMap.put("/druid/**", "anon");
        filterMap.put("/Captcha.jpg","anon");
        filterMap.put("/unlockAccount","anon");
        filterMap.put("/**", "kickout,user");
        filterFactoryBean.setFilterChainDefinitionMap(filterMap);
        return filterFactoryBean;
    }

七、创建ShiroConfig

@Configuration
public class ShiroConfig {
    @Bean
    public UserRealm userRealm(){
        UserRealm userRealm = new UserRealm();
        userRealm.setCachingEnabled(true);
        //启用身份验证缓存,即缓存AuthenticationInfo信息,默认false
        userRealm.setAuthenticationCachingEnabled(true);
        //缓存AuthenticationInfo信息的缓存名称 在ehcache-shiro.xml中有对应缓存的配置
        userRealm.setAuthenticationCacheName("authenticationCache");
        //启用授权缓存,即缓存AuthorizationInfo信息,默认false
        userRealm.setAuthorizationCachingEnabled(true);
        //缓存AuthorizationInfo信息的缓存名称  在ehcache-shiro.xml中有对应缓存的配置
        userRealm.setAuthorizationCacheName("authorizationCache");

        userRealm.setCredentialsMatcher(matcher());
        return userRealm;
    }

    /**
     * 记住我
     * @return
     */
    @Bean
    public CookieRememberMeManager rememberMeManager(){
        CookieRememberMeManager rememberMeManager = new CookieRememberMeManager();
        //cookie对象;会话Cookie模板 ,默认为: JSESSIONID 问题: 与SERVLET容器名冲突,重新定义为sid或rememberMe,自定义
        SimpleCookie cookie = new SimpleCookie("rememberMe");
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        //记住我cookie生效时间30天 ,单位秒;
        cookie.setMaxAge(2592000);
        rememberMeManager.setCookie(cookie);
        //rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)
        rememberMeManager.setCipherKey(Base64.getDecoder().decode("4AvVhmFLUs0KTA3Kprsdag=="));
        return rememberMeManager;
    }

    @Bean
    public RedisManager redisManager(){
        return new RedisManager();
    }

    @Bean
    public RedisSessionDAO redisSessionDAO(){
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setExpire(12000);
        redisSessionDAO.setRedisManager(redisManager());
        return redisSessionDAO;
    }

    /**
     * redis缓存
     * @return
     */
    @Bean
    public RedisCacheManager redisCacheManager(){
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setExpire(200000);
        redisCacheManager.setPrincipalIdFieldName("username");
        redisCacheManager.setRedisManager(redisManager());
        return redisCacheManager;
    }

    @Bean
    public ShiroSessionFactory sessionFactory(){
        return new ShiroSessionFactory();
    }

    /**
     * 配置保存sessionId的cookie
     * 注意:这里的cookie 不是上面的记住我 cookie 记住我需要一个cookie session管理 也需要自己的cookie
     * 默认为: JSESSIONID 问题: 与SERVLET容器名冲突,重新定义为sid
     * @return
     */
    @Bean
    public SimpleCookie sessionIdCookie(){
        //这个参数是cookie的名称
        SimpleCookie simpleCookie = new SimpleCookie("sid");
        //setcookie的httponly属性如果设为true的话,会增加对xss防护的安全系数。它有以下特点:
        //设为true后,只能通过http访问,javascript无法访问
        //防止xss读取cookie
        simpleCookie.setHttpOnly(true);
        simpleCookie.setPath("/");
        //maxAge=-1表示浏览器关闭时失效此Cookie
        simpleCookie.setMaxAge(-1);
        return simpleCookie;
    }

    /**
     * 配置session监听
     * @return
     */
    @Bean
    public ShiroSessionListener sessionListener(){
        return new ShiroSessionListener();
    }

    @Bean
    public ShiroSessionManager sessionManager(){
        ShiroSessionManager sessionManager = new ShiroSessionManager();
        Collection<SessionListener> listeners = new ArrayList<SessionListener>();
        //配置监听
        listeners.add(sessionListener());
        sessionManager.setSessionListeners(listeners);
        sessionManager.setSessionIdCookie(sessionIdCookie());
        sessionManager.setSessionDAO(redisSessionDAO());
        sessionManager.setCacheManager(redisCacheManager());
        sessionManager.setSessionFactory(sessionFactory());
        //全局会话超时时间(单位毫秒),默认30分钟  暂时设置为10秒钟 用来测试
        sessionManager.setGlobalSessionTimeout(1800000);
        //是否开启删除无效的session对象  默认为true
        sessionManager.setDeleteInvalidSessions(true);
        //是否开启定时调度器进行检测过期session 默认为true
        sessionManager.setSessionValidationSchedulerEnabled(true);
        //设置session失效的扫描时间, 清理用户直接关闭浏览器造成的孤立会话 默认为 1个小时
        sessionManager.setSessionValidationInterval(3600000);
        //取消url 后面的 JSESSIONID
        sessionManager.setSessionIdUrlRewritingEnabled(false);
        return sessionManager;
    }


    /**
     *安全管理器
     * @return
     */
    @Bean
    public DefaultWebSecurityManager securityManager(){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(userRealm());
        securityManager.setRememberMeManager(rememberMeManager());
        securityManager.setCacheManager(redisCacheManager());
        securityManager.setSessionManager(sessionManager());
        return securityManager;
    }


    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        filterFactoryBean.setSecurityManager(securityManager);

        //自定义拦截器限制并发人数
        LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>();
        //限制同一帐号同时在线的个数
        filtersMap.put("kickout", kickoutSessionControlFilter());
        filterFactoryBean.setFilters(filtersMap);

        Map<String,String> filterMap = new LinkedHashMap<>();
        filterFactoryBean.setLoginUrl("/login");
        filterFactoryBean.setUnauthorizedUrl("/unauthorized");
        filterMap.put("/logout","logout");
        //过滤
        filterMap.put("/login","anon");
        filterMap.put("/", "anon");
        filterMap.put("/css/**", "anon");
        filterMap.put("/js/**", "anon");
        filterMap.put("/img/**", "anon");
        filterMap.put("/druid/**", "anon");
        filterMap.put("/Captcha.jpg","anon");
        filterMap.put("/unlockAccount","anon");
        filterMap.put("/**", "kickout,user");
        filterFactoryBean.setFilterChainDefinitionMap(filterMap);
        return filterFactoryBean;
    }

    /**
     * 密码匹配器
     * @return
     */
    @Bean
    public RetryLimitHashedCredentialsMatcher matcher(){
        RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher();
        retryLimitHashedCredentialsMatcher.setRedisManager(redisManager());
        //加密算法的名称
       retryLimitHashedCredentialsMatcher.setHashAlgorithmName("MD5");
        //配置加密的次数
        retryLimitHashedCredentialsMatcher.setHashIterations(3);
        return retryLimitHashedCredentialsMatcher;
    }

    /**
     * 并发登录控制
     * @return
     */
    @Bean
    public KickoutSessionControlFilter kickoutSessionControlFilter(){
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        //用于根据会话ID,获取会话进行踢出操作的;
        kickoutSessionControlFilter.setSessionManager(sessionManager());
        //使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
        kickoutSessionControlFilter.setRedisManager(redisManager());
        //是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
        kickoutSessionControlFilter.setKickoutAfter(false);
        //同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
        kickoutSessionControlFilter.setMaxSession(1);
        //被踢出后重定向到的地址;
        kickoutSessionControlFilter.setKickoutUrl("/login?kickout=1");
        return kickoutSessionControlFilter;
    }

    /**
     * FormAuthenticationFilter 过滤器 过滤记住我
     * @return
     */
    @Bean
    public FormAuthenticationFilter formAuthenticationFilter(){
        FormAuthenticationFilter formAuthenticationFilter = new FormAuthenticationFilter();
        //对应前端的checkbox的name = rememberMe
        formAuthenticationFilter.setRememberMeParam("rememberMe");
        return formAuthenticationFilter;
    }

    /**
     * 让某个实例的某个方法的返回值注入为Bean的实例
     * Spring静态注入
     * @return
     */
    @Bean
    public MethodInvokingFactoryBean methodInvokingFactoryBean(){
        MethodInvokingFactoryBean invokingFactoryBean = new MethodInvokingFactoryBean();
        invokingFactoryBean.setStaticMethod("org.apache.shiro.SecurityUtils.setSecurityManager");
        invokingFactoryBean.setArguments(securityManager());
        return invokingFactoryBean;
    }

    /**
     * 必须(thymeleaf页面使用shiro标签控制按钮是否显示
     * 未引入thymeleaf包,Caused by: java.lang.ClassNotFoundException: org.thymeleaf.dialect.AbstractProcessorDialect
     * @return
     */
    @Bean
    public ShiroDialect shiroDialect(){
        return new ShiroDialect();
    }

    /**
     * 开启权限注解支持
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator (){
        DefaultAdvisorAutoProxyCreator autoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        autoProxyCreator.setProxyTargetClass(true);
        return autoProxyCreator;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager){
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    /**
     * 解决: 无权限页面不跳转 shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized") 无效
     * shiro的源代码ShiroFilterFactoryBean.Java定义的filter必须满足filter instanceof AuthorizationFilter,
     * 只有perms,roles,ssl,rest,port才是属于AuthorizationFilter,而anon,authcBasic,auchc,user是AuthenticationFilter,
     * 所以unauthorizedUrl设置后页面不跳转 Shiro注解模式下,登录失败与没有权限都是通过抛出异常。
     * 并且默认并没有去处理或者捕获这些异常。在SpringMVC下需要配置捕获相应异常来通知用户信息
     * @return
     */
    @Bean
    public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
        SimpleMappingExceptionResolver simpleMappingExceptionResolver=new SimpleMappingExceptionResolver();
        Properties properties=new Properties();
        //这里的 /unauthorized 是页面,不是访问的路径
        properties.setProperty("org.apache.shiro.authz.UnauthorizedException","/unauthorized");
        properties.setProperty("org.apache.shiro.authz.UnauthenticatedException","/unauthorized");
        simpleMappingExceptionResolver.setExceptionMappings(properties);
        return simpleMappingExceptionResolver;
    }

    /**
     * 对任意错误码,例如 404 500 等统一跳转到指定页面的配置
     * 注意:SpringBoot 2.0 以上为 WebServerFactoryCustomizer,以下为 EmbeddedServletContainerCustomizer
     */
    @Bean
    public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer() {
        return new WebServerFactoryCustomizer<ConfigurableWebServerFactory>() {
            @Override
            public void customize(ConfigurableWebServerFactory factory) {
                // 配置
                ErrorPage error404Page = new ErrorPage(HttpStatus.NOT_FOUND, "/404.html");
                ErrorPage error500Page = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/500.html");

                factory.addErrorPages(error404Page, error500Page);
            }
        };
    }
}

八、前端页面

  1. resources/templete下面
    1.1 index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
      xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head>
    <meta charset="UTF-8" />
    <title>Insert title here</title>
</head>
<body>
<h1 th:text="'欢迎' + ${user.username } + '光临!请选择你的操作'"></h1><br/>
<ul>
    <h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>

    <shiro:hasPermission name="userInfo:add"><a href="/userInfo/add">点击添加固定用户信息(后台写死,方便测试)</a></shiro:hasPermission><br/>
    <shiro:hasPermission name="userInfo:del"><a href="/userInfo/del">点击删除固定用户信息(后台写死,方便测试)</a></shiro:hasPermission><br/>
    <shiro:hasPermission name="userInfo:view"><a href="/userInfo/view">显示此内容表示拥有查看用户列表的权限</a></shiro:hasPermission><br/>
    <!-- 用户没有身份验证时显示相应信息,即游客访问信息 -->
    <shiro:guest>游客显示的信息</shiro:guest><br/>
    <!-- 用户已经身份验证/记住我登录后显示相应的信息 -->
    <shiro:user>用户已经登录过了</shiro:user><br/>
    <!-- 用户已经身份验证通过,即Subject.login登录成功,不是记住我登录的 -->
    <shiro:authenticated>不是记住我登录</shiro:authenticated><br/>
    <!-- 显示用户身份信息,通常为登录帐号信息,默认调用Subject.getPrincipal()获取,即Primary Principal -->
    <shiro:principal></shiro:principal><br/>
    <!--用户已经身份验证通过,即没有调用Subject.login进行登录,包括记住我自动登录的也属于未进行身份验证,与guest标签的区别是,该标签包含已记住用户 -->
    <shiro:notAuthenticated>已记住用户</shiro:notAuthenticated><br/>
    <!-- 相当于Subject.getPrincipals().oneByType(String.class) -->
    <shiro:principal type="java.lang.String"/><br/>
    <!-- 相当于((User)Subject.getPrincipals()).getUsername() -->
    <shiro:principal property="username"/><br/>
    <!-- 如果当前Subject有角色将显示body体内容 name="角色名" -->
    <shiro:hasRole name="admin">这是admin角色</shiro:hasRole><br/>
    <!-- 如果当前Subject有任意一个角色(或的关系)将显示body体内容。 name="角色名1,角色名2..." -->
    <shiro:hasAnyRoles name="admin,vip">用户拥有admin角色 或者 vip角色</shiro:hasAnyRoles><br/>
    <!-- 如果当前Subject没有角色将显示body体内容 -->
    <shiro:lacksRole name="admin">如果不是admin角色,显示内容</shiro:lacksRole><br/>
    <!-- 如果当前Subject有权限将显示body体内容 name="权限名" -->
    <shiro:hasPermission name="userInfo:add">用户拥有添加权限</shiro:hasPermission><br/>
    <!-- 用户同时拥有以下两种权限,显示内容 -->
    <shiro:hasAllPermissions name="userInfo:add,userInfo:view">用户同时拥有列表权限和添加权限</shiro:hasAllPermissions><br/>
    <!-- 用户拥有以下权限任意一种 -->
    <shiro:hasAnyPermissions name="userInfo:view,userInfo:del">用户拥有列表权限或者删除权限</shiro:hasAnyPermissions><br/>
    <!-- 如果当前Subject没有权限将显示body体内容 name="权限名" -->
    <shiro:lacksPermission name="userInfo:add">如果用户没有添加权限,显示的内容</shiro:lacksPermission><br/>
</ul>
<a href="/logout">点我注销</a>
</body>
</html>

1.2 login.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>Insert title here</title>
</head>
<body>
<h1>欢迎登录</h1>
<h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1>
<form action="/login" method="post">
    用户名:<input type="text" name="username"/><br/>
    密码:<input type="password" name="password"/><br/>
    <input type="checkbox" name="rememberMe" />记住我<br/>
    <input type="submit" value="提交"/> <button><a href="/unlockAccount">解锁admin用户</a></button>
</form>
</body>
<!--<script type="text/javascript" th:src="@{/js/jquery.js}"></script>-->
<script type="text/javascript">
    function kickout(){
        var href=location.href;
        if(href.indexOf("kickout")>0){
            alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!");
        }
    }
    window.onload=kickout();
</script>
</html>

1.3 unauthorized.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8" />
    <title>Insert title here</title>
</head>
<body>
<h1>对不起,您没有权限</h1>
</body>
</html>
  1. resources/static下面
    2.1 404.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
404
</body>
</html>

2.2 500.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
500
</body>
</html>

九、控制层

9.1 LoginController

@Controller
public class LoginController {
    private static final String KEY_CAPTCHA = "KEY_CAPTCHA";

    @Autowired
    RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher;

    @RequestMapping({"/","index"})
    public String root(Model model){
        User user = (User) SecurityUtils.getSubject().getPrincipal();
        if(null == user){
            return "redirect:/login";
        }else {
            model.addAttribute("user",user);
            return "index";
        }
    }
    @RequestMapping("login")
    public String login(){
        User user = (User) SecurityUtils.getSubject().getPrincipal();
        if(null == user){
            return "login";
        }else {
            return "redirect:/index";
        }
    }
    /**
     * 跳转到无权限页面
     * @return
     */
    @RequestMapping("/unauthorized")
    public String unauthorized() {
        return "unauthorized";
    }

    /**
     * 用户登录
     * @param username
     * @param password
     * @param model
     * @param session
     * @return
     */
    @RequestMapping(value = "/login",method = RequestMethod.POST)
    public String loginUser(String username,boolean remeberMe, String password,Model model, HttpSession session) {
        
        //对密码进行加密
        //password=new SimpleHash("md5", password, ByteSource.Util.bytes(username.toLowerCase() + "shiro"),2).toHex();
        //如果有点击  记住我
        UsernamePasswordToken usernamePasswordToken=new UsernamePasswordToken(username,password,remeberMe);
        //UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username,password);
        Subject subject = SecurityUtils.getSubject();
        try {
            //登录操作
            subject.login(usernamePasswordToken);
            User user=(User) subject.getPrincipal();
            //更新用户登录时间,也可以在ShiroRealm里面做
            session.setAttribute("user", user);
            model.addAttribute("user",user);
            return "redirect:index";
        } catch(Exception e) {
            if(e instanceof UnknownAccountException){
                model.addAttribute("msg","用户名或者密码错误");
            }
            if(e instanceof IncorrectCredentialsException){
                model.addAttribute("msg","用户名或者密码错误");
            }if(e instanceof LockedAccountException){
                model.addAttribute("msg","账户已被锁定,请联系管理员");
            }
            //返回登录页面
            return "login";
        }
    }

    /**
     * 解除admin 用户的限制登录
     * 写死的 方便测试
     * @return
     */
    @RequestMapping("/unlockAccount")
    public String unlockAccount(Model model){
        model.addAttribute("msg","用户解锁成功");

        retryLimitHashedCredentialsMatcher.unlockAccount("admin");

        return "login";
    }

    @RequestMapping("/Captcha.jpg")
    public void getCaptcha(HttpServletRequest request, HttpServletResponse response)throws ServletException, IOException {
        // 设置相应类型,告诉浏览器输出的内容为图片
        response.setContentType("image/jpeg");
        // 不缓存此内容
        response.setHeader("Pragma", "No-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expire", 0);
        try {

            HttpSession session = request.getSession();

            CaptchaUtil tool = new CaptchaUtil();
            StringBuffer code = new StringBuffer();
            BufferedImage image = tool.genRandomCodeImage(code);
            session.removeAttribute(KEY_CAPTCHA);
            session.setAttribute(KEY_CAPTCHA, code.toString());

            // 将内存中的图片通过流动形式输出到客户端
            ImageIO.write(image, "JPEG", response.getOutputStream());

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

9.2 UserController

@Controller
@RequestMapping("/userInfo")
public class UserController {

    @Autowired
    UserMapper userMapper;

    @Autowired
    RoleMapper roleMapper;

    /**
     * 创建固定写死的用户
     * @param model
     * @return
     */
    @RequestMapping(value = "/add",method = RequestMethod.GET)
    @ResponseBody
    public String login(Model model) {

        User user = new User();
        user.setName("张三");
        user.setIdcard("623171313213131");
        user.setUsername("zs");
        userMapper.insert(user);
        return "创建用户成功";

    }

    /**
     * 删除固定写死的用户
     * @param model
     * @return
     */
    @RequiresPermissions("userInfo:del")
    @RequestMapping(value = "/del",method = RequestMethod.GET)
    @ResponseBody
    public String del(Model model) {

        userMapper.del("zs");
        return "删除用户名为zs用户成功";
    }

    @RequestMapping(value = "/view",method = RequestMethod.GET)
    @ResponseBody
    public String view(Model model) {
        return "这是用户列表页";
    }

    /**
     * 给admin用户添加 userInfo:del 权限
     * @param model
     * @return
     */
    @RequestMapping(value = "/addPermission",method = RequestMethod.GET)
    @ResponseBody
    public String addPermission(Model model) {
        //在sys_role_permission 表中  将 删除的权限 关联到admin用户所在的角色
        roleMapper.addPermission(1,3);
        //添加成功之后 清除缓存
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
        UserRealm userRealm = (UserRealm) securityManager.getRealms().iterator().next();
        return "给admin用户添加 userInfo:del 权限成功";
    }

    /**
     * 删除admin用户 userInfo:del 权限
     * @param model
     * @return
     */
    @RequestMapping(value = "/delPermission",method = RequestMethod.GET)
    @ResponseBody
    public String delPermission(Model model) {

        //在sys_role_permission 表中  将 删除的权限 关联到admin用户所在的角色
        roleMapper.delPermission(1,3);
        //添加成功之后 清除缓存
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager)SecurityUtils.getSecurityManager();
        UserRealm userRealm = (UserRealm) securityManager.getRealms().iterator().next();
        return "删除admin用户userInfo:del 权限成功";
    }
}

十、数据库创建

-- UserInfo用户信息表
create table user_info(
uid int(11) primary key not null auto_increment comment '主键',
username varchar(20) unique key default '' comment '用户名',
password varchar(255) default null comment '登录密码',
name varchar(20) default null comment '用户真实姓名',
phone varchar(50) default null comment '手机号码',
idcard varchar(50) unique key default null comment '身份证号',
state char(1) default '0' comment '用户状态;0:正常状态,1:账户被锁定'
) 
#插入用户信息表
insert into user_info (username,password,name,phone,idcard,state)values('admin','123456','admin','15925612271','62171331418123','0');
insert into user_info (username,password,name,phone,idcard,state)values('test','123456','test','15925612271','62171331418122','0');

-- sys_role角色表
create table sys_role(
role_id int(11) primary key not null auto_increment comment '角色表主键',
role_name varchar(50) unique key default null comment '角色名称',
avaliable char(1) default '0' comment '是否可用:0可用1不可用',
description varchar(100) default null comment '角色描述'
)
#插入用户角色表
insert into sys_role(role_name,avaliable,description)values('admin',0,'管理员');
insert into sys_role(role_name,avaliable,description)values('vip',0,'VIP会员');
insert into sys_role(role_name,avaliable,description)values('test',1,'测试');

-- sys_user_role用户角色表
create table sys_user_role(
uid int(11) default null comment '用户id',
rid int(11) default null comment '角色id',
constraint FK_user_info foreign key(uid) references user_info(uid),
constraint FK_sys_role foreign key(rid) references sys_role(role_id)
)
#插入用户_角色关联表
INSERT INTO `sys_user_role` (`uid`,`rid`) VALUES (1,1);
INSERT INTO `sys_user_role` (`uid`,`rid`) VALUES (2,2);

-- sys_permission权限表
create table sys_permission(
id int(11) primary key not null auto_increment comment '权限表主键',
parent_id int(11) default null comment '父权限编号,本权限可能是该父编号权限的子权限',
parent_ids varchar(20) default null comment '父编号列表',
permission_Code varchar(255) default null comment '权限编码,menu例子:role:*,button例子:role:create,role:update,role:delete,role:view',
permission_Name varchar(50) default null comment '权限名称',
resource_type varchar(50) default null comment '资源类型,[menu|button]',
url varchar(255) default null comment '资源路径 如:/userinfo/list',
avaliable char(1) default '0' comment '是否可用0可用  1不可用'
)
#插入权限表
insert into sys_permission(parent_id,parent_ids,permission_code,permission_name,resource_type,url,avaliable)values(0,'0/','userInfo:view','用户管理','menu','userInfo/view',0);
insert into sys_permission(parent_id,parent_ids,permission_code,permission_name,resource_type,url,avaliable)values(1,'0/1','userInfo:add','用户添加','button','userInfo/add',0);
insert into sys_permission(parent_id,parent_ids,permission_code,permission_name,resource_type,url,avaliable)values(1,'0/1','userInfo:del','用户删除','button','userInfo/del',0);


-- sys_role_permission 角色权限表
create table sys_role_permission(
role_id int(11) default null comment '角色id',
permission_id int(11) default null comment '权限id',
constraint FK_sys_role_1 foreign key(role_id) references sys_role(role_id),
constraint FK_sys_permission foreign key(permission_id) references sys_permission(id)
)
#插入角色_权限表
INSERT INTO `sys_role_permission` (`role_id`,`permission_id`) VALUES (1,1);
INSERT INTO `sys_role_permission` (`role_id`,`permission_id`) VALUES (1,2);
INSERT INTO `sys_role_permission` (`role_id`,`permission_id`) VALUES (2,3);

十一、如果用户的权限发生改变怎么办?

上面已经启用了缓存,第一次请求走数据库查询,后续请求将直接查询redis缓存,假如这个时候在权限控制台分配了某个权限给某个角色,那么拥有这个角色的所有用户在下次请求之前都需要从数据库查询最新的权限信息。下面开始进行在权限发生改变时,该如何做:
step1:
在ShiroRealm中添加以下方法(清理缓存)

/**
 * 重写方法,清除当前用户的的 授权缓存
 * @param principals
 */
@Override
public void clearCachedAuthorizationInfo(PrincipalCollection principals) {
    super.clearCachedAuthorizationInfo(principals);
}
/**
 * 重写方法,清除当前用户的 认证缓存
 * @param principals
 */
@Override
public void clearCachedAuthenticationInfo(PrincipalCollection principals) {
    super.clearCachedAuthenticationInfo(principals);
}
@Override
public void clearCache(PrincipalCollection principals) {
    super.clearCache(principals);
}
/**
 * 自定义方法:清除所有 授权缓存
 */
public void clearAllCachedAuthorizationInfo() {
    getAuthorizationCache().clear();
}
/**
 * 自定义方法:清除所有 认证缓存
 */
public void clearAllCachedAuthenticationInfo() {
    getAuthenticationCache().clear();
}
/**
 * 自定义方法:清除所有的  认证缓存  和 授权缓存
 */
public void clearAllCache() {
    clearAllCachedAuthenticationInfo();
    clearAllCachedAuthorizationInfo();
}

step2:
在shiroConfig中添加以下bean

/**
 * 让某个实例的某个方法的返回值注入为Bean的实例
 * Spring静态注入
 * @return
 */
@Bean
public MethodInvokingFactoryBean getMethodInvokingFactoryBean(){
    MethodInvokingFactoryBean factoryBean = new MethodInvokingFactoryBean();
    factoryBean.setStaticMethod("org.apache.shiro.SecurityUtils.setSecurityManager");
    factoryBean.setArguments(new Object[]{securityManager()});
    return factoryBean;
}

step3:
在UserController中添加下面两个方法

/**
     * 给admin用户添加 userInfo:del 权限
     * @param model
     * @return
     */
    @RequestMapping(value = "/addPermission",method = RequestMethod.GET)
    @ResponseBody
    public String addPermission(Model model) {

        //在sys_role_permission 表中  将 删除的权限 关联到admin用户所在的角色
        roleMapper.addPermission(1,3);

        //添加成功之后 清除缓存
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager)SecurityUtils.getSecurityManager();
        ShiroRealm shiroRealm = (ShiroRealm) securityManager.getRealms().iterator().next();
        //清除权限 相关的缓存
        shiroRealm.clearAllCache();
        return "给admin用户添加 userInfo:del 权限成功";
    }

    /**
     * 删除admin用户 userInfo:del 权限
     * @param model
     * @return
     */
    @RequestMapping(value = "/delPermission",method = RequestMethod.GET)
    @ResponseBody
    public String delPermission(Model model) {

        //在sys_role_permission 表中  将 删除的权限 关联到admin用户所在的角色
        roleMapper.delPermission(1,3);
        //添加成功之后 清除缓存
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager)SecurityUtils.getSecurityManager();
        ShiroRealm shiroRealm = (ShiroRealm) securityManager.getRealms().iterator().next();
        //清除权限 相关的缓存
        shiroRealm.clearAllCache();

        return "删除admin用户userInfo:del 权限成功";
    }

注意:在添加权限 或者 删除权限之后 都有调用shiroRealm.clearAllCache();来清除所有的缓存。
调试:
在index.html中添加
<shiro:hasAllPermissions name=“userInfo:view,userInfo:del”>用户同时拥有列表权限和删除权限</shiro:hasAllPermissions>

步骤:登陆成功后
在这里插入图片描述
先http://localhost:6677/userInfo/addPermission显示权限添加完成
在这里插入图片描述
在这里插入图片描述
再http://localhost:6677/userInfo/delPermission显示权限删除完成
在这里插入图片描述
在这里插入图片描述

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一位不知名民工

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值