SpringBoot + Redis + Shiro + JWT单点登录主流安全框架

29 篇文章 0 订阅
1 篇文章 0 订阅

主流联合单点登录安全框架基础配置

目录

SpringBoot集成Redis、Shiro、JWT

JWT负责签发、刷新、解析、校验Token

Shiro负责使用JWT工具类中的方法、拦截请求

Redis用于实现Shiro源码中的从缓存中取得认证数据的操作。

项目本身分为如下步骤

实战脚手架搭建-创建项目基本骨架

数据库设计和配置

druid 连接池和数据监控配置

日志配置

逆向生成代码和配置mybatis再次学习

集成swagger2

集成redis

前后端分离数据封装 DataResult

全局异常统一处理

Hibernate Validator 详解

10分钟搞懂:JWT(Json Web Token)

JWT 工具类封装

实现用户认证签发 token

mybatis使用 pagehelper 实现分页封装

Spring Boot+Shiro+JWT+redis 前后端分离脚手架-自定义token认证

Spring Boot+Shiro+JWT+redis 前后端分离脚手架-自定义 Realm

Spring Boot+Shiro+JWT+redis 脚手架-自定义用户认证匹配方法

Spring Boot+Shiro+JWT+redis 前后端分离脚手架-shiro 策略配置

Spring Boot+Shiro+JWT+redis 前后端分离脚手架-授权验证

SpringBoot+Shiro+JWT+redis前后端分离脚手架-redis缓存授权信息

依赖如下

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--mysql-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.19</version>
        </dependency>
        <!--连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>
        <!--分页查询插件1-->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.2.5</version>
        </dependency>
        <!--分页查询插件2-->
        <dependency>
            <groupId>com.github.jsqlparser</groupId>
            <artifactId>jsqlparser</artifactId>
            <version>1.0</version>
        </dependency>
        <!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--redis连接池-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--shiro配置-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.4.1</version>
        </dependency>
        <!--JWT-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <!--AOP-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <!--swagger-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>3.0.0</version>
        </dependency>
        <!--swagger的UI-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>3.0.0</version>
        </dependency>
        <!--热部署工具-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <!--热部署策略-->
                    <fork>true</fork>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

配置druid数据源

在application.properties中配置如下信息

server.port=8080
# 设置服务名称 在微服务时使用
spring.application.name=company-frame
#数据库配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.druid.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.druid.url=jdbc:mysql://localhost:3306/company_frame?useUnicode=true&&characterEncoding=utf8&&useSSL=false&&serverTimezone=GMT%2B8
spring.datasource.druid.username=root
spring.datasource.druid.password=root123


################## ?连接池配置 ?################
#连接池建立时创建的初始化连接数
spring.datasource.druid.initial-size=5
#连接池中最大的活跃连接数
spring.datasource.druid.max-active=20
#连接池中最小的活跃连接数
spring.datasource.druid.min-idle=5
# 配置获取连接等待超时的时间
spring.datasource.druid.max-wait=60000
# 打开PSCache,并且指定每个连接上PSCache的大小
spring.datasource.druid.pool-prepared-statements=true
spring.datasource.druid.max-pool-prepared-statement-per-connection-size=20
spring.datasource.druid.validation-query=SELECT 1 FROM DUAL 
spring.datasource.druid.validation-query-timeout=30000
#是否在获得连接后检测其可用性
spring.datasource.druid.test-on-borrow=false
#是否在连接放回连接池后检测其可用性
spring.datasource.druid.test-on-return=false
#是否在连接空闲一段时间后检测其可用性
spring.datasource.druid.test-while-idle=true
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
spring.datasource.druid.time-between-eviction-runs-millis=60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
spring.datasource.druid.min-evictable-idle-time-millis=300000
# 监控后台账号和密码
spring.datasource.druid.stat-view-servlet.login-username=admin
spring.datasource.druid.stat-view-servlet.login-password=66666666
测试:

启动应用程序——浏览器输入:http://localhost:8080/druid

配置日志文件

#日志配置
#logging配置
# 日志文件名称
logging.file=${logging.path}/${spring.application.name}.log
# 路径
logging.path=logs
# 日志级别
logging.level.com.wz.lesson=debug

测试:运行后查看此处

在这里插入图片描述

逆向生成代码和配置mybatis

此处逆向生成代码并不是使用Mybatis-plus生成的

在配置文件文件夹中 新建 generatorConfig.xml

内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN" "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<!--代码逆向生成(非mybatis-plus,是mybatis-generator)-->
<generatorConfiguration>
        <!--classPathEntry:数据库的JDBC驱动,换成你自己的驱动位置 -->
        <classPathEntry location="C:\Users\ASUS\.m2\repository\mysql\mysql-connector-java\8.0.19\mysql-connector-java-8.0.19.jar" />
        <!-- 一个数据库一个context -->
        <!--defaultModelType="flat" 大数据字段,不分表 -->
        <context id="MysqlTables" targetRuntime="MyBatis3" defaultModelType="flat">
                <property name="autoDelimitKeywords" value="true"/>
                <property name="beginningDelimiter" value="`"/>
                <property name="endingDelimiter" value="`"/>
                <property name="javaFileEncoding" value="utf-8"/>
                <plugin type="org.mybatis.generator.plugins.SerializablePlugin"/>
                <plugin type="org.mybatis.generator.plugins.ToStringPlugin"/>
        <!-- 注释 -->
                <commentGenerator>
                        <property name="suppressAllComments" value="true"/><!-- 是否取消注释 -->
                        <property name="suppressDate" value="true"/> <!-- 是否生成注释代时间戳-->
                </commentGenerator>
        <!-- jdbc连接 -->
                <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver" connectionURL="jdbc:mysql://localhost:3306/company_frame?useUnicode=true&amp;characterEncoding=utf8&amp;useSSL=false&amp;serverTimezone=GMT%2B8" userId="root" password="root123"/>
                <!-- 类型转换 -->
                <javaTypeResolver>
                        <!-- 是否使用bigDecimal, false可自动转化以下类型(Long, Integer, Short, etc.) -->
                        <property name="forceBigDecimals" value="false"/>
                </javaTypeResolver>
                <!-- 生成实体类地址 -->
                <javaModelGenerator targetPackage="com.wz.lesson.entity" targetProject="src/main/java">
                        <property name="enableSubPackages" value="false"/>
                        <property name="trimStrings" value="true"/>
                </javaModelGenerator>
                <!-- 生成mapxml文件 -->
                <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources">
                        <property name="enableSubPackages" value="false"/>
                </sqlMapGenerator>
                <!-- 生成mapxml对应client,也就是接口dao -->
                <javaClientGenerator targetPackage="com.wz.lesson.mapper" targetProject="src/main/java" type="XMLMAPPER">
                        <property name="enableSubPackages" value="false"/>
                </javaClientGenerator>
                <!--插件扩展类设置为false,避免自动生成SQL语句,以达成练习效果-->
                <table tableName="sys_user" domainObjectName="SysUser"
                       enableCountByExample="false"
                       enableUpdateByExample="false"
                       enableDeleteByExample="false"
                       enableSelectByExample="false"
                       selectByExampleQueryId="true">
                        <!--生成实体类时候指定部分字段的数据类型-->
                        <columnOverride column="sex" javaType="java.lang.Integer"/>
                        <columnOverride column="status" javaType="java.lang.Integer"/>
                        <columnOverride column="create_where" javaType="java.lang.Integer"/>
                        <columnOverride column="deleted" javaType="java.lang.Integer"/>
                </table>
        </context>
</generatorConfiguration>
问题

generatorConfig.xml的头文件http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd报红

解决方案:左边有红色小灯泡,点击Fetch external resource即可解决

pom.xml中添加相应插件配置plugin

            <!--配置mybatis代码生成工具-->
            <!--使用生成工具可以直接使用maven的命令提示符,
            生成语句是mvn mybatis-generator:generate ,
            一旦数据库进行了更改,都需使用这句代码重新生成bean、dao、mapper文件-->
            <plugin>
                <groupId>org.mybatis.generator</groupId>
                <artifactId>mybatis-generator-maven-plugin</artifactId>
                <version>1.3.5</version>
                <configuration>
                    <configurationFile>src/main/resources/generatorConfig.xml</configurationFile>
                    <verbose>true</verbose>
                    <overwrite>true</overwrite>
                </configuration>
                <executions>
                    <execution>
                        <phase>deploy</phase>
                        <id>Generate MyBatis Artifacts</id>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
                <dependencies>
                    <dependency>
                        <groupId>org.mybatis.generator</groupId>
                        <artifactId>mybatis-generator-core</artifactId>
                        <version>1.3.5</version>
                    </dependency>
                </dependencies>
            </plugin>

执行生成操作

在这里插入图片描述

配置 mybatis

修改 application.properties

# MyBatis
# MyBatis配置数据映射器位置
mybatis.mapper-locations=classpath:mapper/*.xml

配置完毕

集成swagger2

设置开关

注:往往线上一般是要把 swagger 接口入口给关闭而开发测试会打开,所以我们可以在配置文件上设置一个开关,多环境打包时候给 出相应的值就可以了

修改 application.properties
# Swagger
# Swagger2在不同开发环境下的开关配置,根据不同的环境,将配置文件中的此字段改为true或者false,true为启动
swagger.enable=true

创建 SwaggerConfig

package com.wz.lesson.config;

import org.apache.ibatis.annotations.Param;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.ArrayList;
import java.util.List;

@Configuration
@EnableSwagger2 // 开启swagger2
public class SwaggerConfig {
    // 开关配置,此处关联配置文件中的是否启用swagger2的开关字段swagger.enable
    @Value("${swagger.enable}")
    private boolean enable; // enable的值就等于swagger.enable

    // swagger文档的配置
    @Bean
    public Docket createDocket(){
        // 全局的swagger入口。作用是使swagger发起head请求
        List<Parameter> lists =new ArrayList<>();
        ParameterBuilder accessToken = new ParameterBuilder(); // 准许进入的Token
        ParameterBuilder refreshToken = new ParameterBuilder();// 刷新的Token
        // 设置accessToken的规则
        // 参数名为authorization,描述内容"程序员自己测试时动态传入Token的入口。",参数的类型是String(string注意是小写),请求的方式是header请求,是否必须为false
        accessToken.name("authorization").description("程序员自己测试时动态传输accessToken的入口。")
        .modelRef(new ModelRef("string")).parameterType("header").required(false);
        // 设置refreshToken的规则
        refreshToken.name("refreshToken").description("程序员自己测试时动态传输refreshToken的入口。")
                .modelRef(new ModelRef("string")).parameterType("header").required(false);

        // 将设置好的accessToken规则和refreshToken规则传入集合
        lists.add(accessToken.build());
        lists.add(refreshToken.build());

        return new Docket(DocumentationType.SWAGGER_2) // 类型
                .apiInfo(apiInfo()) // 描述 // 调用我们自己的方法
                .select() // 扫描
                .apis(RequestHandlerSelectors.basePackage("com.wz.lesson.controller")) // 指定扫描的包
                .paths(PathSelectors.any()) // 扫描范围是any,即扫描所有
                .build()// 构建操作
                .globalOperationParameters(lists) // 将上面设置的两种token规则传入
                .enable(enable); // 根据配置文件中的值来判断是否启用swagger
    }

    // 配置api文档的描述
    private ApiInfo apiInfo(){
        return new ApiInfoBuilder()
                .title("实战")
                .description("后端Api")
                .termsOfServiceUrl("") // 扫描的Url
                .version("1.0")
                .build();// 执行的操作:构建
    }

}

集成测试Swagger

创建 TestController
package com.wz.lesson.controller;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @ClassName: TestController
*/
@RestController
@Api(tags = "测试接口模块",description = "主要是为了提供测试接口用")
@RequestMapping("/test")
public class TestController {
    @GetMapping("/index")
    @ApiOperation(value = "引导页接口")
    public String testResult(){
        return "Hello World";
   }
}

浏览器输入 http://localhost:8080/swagger-ui.html测试

集成redis

redis连接池配置

# Redis
# spring.redis.host是指远程服务器的公网ip
spring.redis.host=127.0.0.1
# 端口
spring.redis.port=6379
# 连接池最大连接数(使用负值表示没有限制) 默认 8
spring.redis.lettuce.pool.max-active=100
# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
spring.redis.lettuce.pool.max-wait=PT10S
# 连接池中的最大空闲连接 默认 8
spring.redis.lettuce.pool.max-idle=30
# 连接池中的最小空闲连接 默认 0
spring.redis.lettuce.pool.min-idle=1
#链接超时时间
spring.redis.timeout=PT10S

自定义 redis 序列化方式

package com.wz.lesson.serializer;

import com.alibaba.fastjson.JSON;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.util.Assert;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
 * 自定义Redis的序列化
 *
 * 重写serialize()方法,用于解决Java将数据通过RedisTemplate传入Redis时数据非String类型的情况导致报错的问题
 * 虽然Redis是非关系型数据库,但是RedisTemplate只支持传入String类型,所以我们在此次进行一次转换,将传入的Object类型转换为JSON字符串类型(即String类型),这样我们就可以使用了。
 * 此类的作用是在使用RedisTemplate调用Redis时,默认的参数是RedisTemplate<String,String>,我们将其改为RedisTemplate<String,Object>
 * ,在序列化传入时加一个判断,把Object转换成JSON,本质上还是使用的默认序列化方式,只不过加上了一个Object到JSON转换的过程
 * ------------
 * 实际上我们可以不需要这样操作,因为我们可以在调用时,把传入的key-value全部以转换为JSON格式后传入,这样就可以使用默认的RedisTemplate了
 *  实际开发中,数据的传递都是使用JSON的,所以此类和直接在传入时就传JSON实际上是殊途同归,只不过把这个步骤滞后了。
 */
public class MyStringRedisSerializer implements RedisSerializer<Object> {
    private final Charset charset;

    public MyStringRedisSerializer() {
        this(StandardCharsets.UTF_8);
    }

    public MyStringRedisSerializer(Charset charset) {
        Assert.notNull(charset, "Charset must not be null!");
        this.charset = charset;
    }

    @Override
    public String deserialize(byte[] bytes) {
        return (bytes == null ? null : new String(bytes, charset));
    }

    /**
     * 我们修改此方法
     * @param object
     * @return
     */
    @Override
    public byte[] serialize(Object object) {
        if (object == null) {
            return new byte[0];
        }
        // 判断:如果是String类型,则传入并使其序列化
        if (object instanceof String) {
            return object.toString().getBytes(charset);
        } else { // 如果不是String类型的数据,则使用fastjson来将其转换成String类型再传入
            String string = JSON.toJSONString(object);
            return string.getBytes(charset);
        }
    }
}
配置 redis 指定它的序列化方式
package com.wz.lesson.config;

import com.wz.lesson.serializer.MyStringRedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis配置类
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String,Object> template = new RedisTemplate<>();
        //
        template.setConnectionFactory(redisConnectionFactory);
        // 指定Key的序列化方式是默认
        template.setKeySerializer(new StringRedisSerializer());
        // 指定Value的序列化方式是我们自己定义的
        template.setValueSerializer(new MyStringRedisSerializer());
        // 指定HashKey的序列化方式是默认
        template.setHashKeySerializer(new StringRedisSerializer());
        // 指定HashValue的序列化方式是我们自己定义的
        template.setHashValueSerializer(new MyStringRedisSerializer());

        return template;
    }
}
redis实战工具类封装RedisService

并不是只有写了此类才能操作redis,也不是此类中有特殊的处理方法。

我们也可以不使用此类,在需要指定功能时现场编写。

此类的作用是把常用的操作redis的方式集成起来,以便于我们在写业务代码时直接调用。

此类属于业务逻辑操作,在Service层包中创建此类即可。并不是必须要放入utils包中。

package com.wz.lesson.service;

import com.wz.lesson.exception.BusinessException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * Redis使用工具类
 * 此类和狂神的整合方式一样,只不过在此处使用工具类进行封装,调用时没有直接使用RedisTemplate进行调用,而是在将其常用的Redis功能在RedisService中使用RedisTemplate里调用并封装,随后我们直接使用RedisService调用即可
 * 粘贴即用
 */
@Service
public class RedisService {
    // 注意此处RedisTemplate<String,Object>中<String,Object>需要和自定义的一致,否则就会使用默认的RedisTemplate<String,String>
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    /** -------------------key相关操作--------------------- */

    /**
     * 是否存在key
     * @Author:      
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Boolean
     * @throws
     */
    public Boolean hasKey(String key) {
        if (null==key){
            return false;
        }
        return redisTemplate.hasKey(key);
    }

    /**
     * 删除key
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       Boolean  成功返回true 失败返回false
     * @throws
     */
    public Boolean delete(String key) {
        if (null==key){
            return false;
        }
        return redisTemplate.delete(key);
    }

    /**
     * 批量删除key
     * @Author:       
     * @CreateDate:  2019/8/27 20:27
     * @UpdateUser:
     * @UpdateDate:  2019/8/27 20:27
     * @Version:     0.0.1
     * @param keys
     * @return       Long 返回成功删除key的数量
     * @throws
     */
    public Long delete(Collection<String> keys) {
        return redisTemplate.delete(keys);
    }


    /**
     * 设置过期时间
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param timeout
     * @param unit
     * @return       java.lang.Boolean
     * @throws
     */
    public Boolean expire(String key, long timeout, TimeUnit unit) {
        if (null==key||null==unit){
            return false;
        }
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 查找匹配的key
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param pattern
     * @return       java.util.Set<java.lang.String>
     * @throws
     */
    public Set<String> keys(String pattern) {
        if (null==pattern){
            return null;
        }
        return redisTemplate.keys(pattern);
    }


    /**
     * 移除 key 的过期时间,key 将持久保持
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Boolean
     * @throws
     */
    public Boolean persist(String key) {
        if (null==key){
            return false;
        }
        return redisTemplate.persist(key);
    }

    /**
     * 返回 key 的剩余的过期时间
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param unit
     * @return       java.lang.Long 当 key 不存在时,返回 -2 。当 key 存在但没有设置剩余生存时间时,返回 -1 。否则,以秒为单位,返回 key的剩余生存时间
     * @throws
     */
    public Long getExpire(String key, TimeUnit unit) {
        if(null==key||null==unit){
            throw new BusinessException(4001004,"key or TomeUnit 不能为空");
        }
        return redisTemplate.getExpire(key, unit);
    }

    //*************String相关数据类型***************************
    /**
     * 设置指定 key 的值
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param value
     * @return       void
     * @throws
     */
    public void set(String key, Object value) {

        if(null==key||null==value){
            return;
        }
        redisTemplate.opsForValue().set(key, value);
    }
    /**
     * 设置key 的值 并设置过期时间
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param value
     * @param time
     * @param unit
     * @return       void
     * @throws
     */
    public void set(String key,Object value,long time,TimeUnit unit){

        if(null==key||null==value||null==unit){
            return;
        }
        redisTemplate.opsForValue().set(key,value,time,unit);
    }
    /**
     * 设置key 的值 并设置过期时间
     * key存在 不做操作返回false
     * key不存在设置值返回true
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param value
     * @param time
     * @param unit
     * @return       java.lang.Boolean
     * @throws
     */
    public Boolean setifAbsen(String key,Object value,long time,TimeUnit unit){

        if(null==key||null==value||null==unit){
            throw new BusinessException(4001004,"kkey、value、unit都不能为空");
        }
        return redisTemplate.opsForValue().setIfAbsent(key,value,time,unit);
    }
    /**
     * 获取指定Key的Value。如果与该Key关联的Value不是string类型,Redis将抛出异常,
     * 因为GET命令只能用于获取string Value,如果该Key不存在,返回null
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Object
     * @throws
     */
    public Object get(String key){

        if(null==key){
            return null;
        }
        return  redisTemplate.opsForValue().get(key);
    }
    /**
     * 很明显先get再set就说先获取key值对应的value然后再set 新的value 值。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param value
     * @return       java.lang.Object
     * @throws
     */
    public Object getSet(String key,Object value){

        if(null==key){
            return null;
        }
        return redisTemplate.opsForValue().getAndSet(key,value);
    }
    /**
     * 通过批量的key获取批量的value
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param keys
     * @return       java.util.List<java.lang.Object>
     * @throws
     */
    public List<Object> mget(Collection<String> keys){

        if(null==keys){
            return Collections.emptyList();
        }
        return redisTemplate.opsForValue().multiGet(keys);
    }
    /**
     *  将指定Key的Value原子性的增加increment。如果该Key不存在,其初始值为0,在incrby之后其值为increment。
     *  如果Value的值不能转换为整型值,如Hi,该操作将执行失败并抛出相应异常。操作成功则返回增加后的value值。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param increment
     * @return       long
     * @throws
     */
    public long incrby(String key,long increment){
        if(null==key){
            throw new BusinessException(4001004,"key不能为空");
        }
        return redisTemplate.opsForValue().increment(key,increment);
    }
    /**
     *
     * 将指定Key的Value原子性的减少decrement。如果该Key不存在,其初始值为0,
     * 在decrby之后其值为-decrement。如果Value的值不能转换为整型值,
     * 如Hi,该操作将执行失败并抛出相应的异常。操作成功则返回减少后的value值。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param decrement
     * @return       java.lang.Long
     * @throws
     */
    public Long decrby(String key,long decrement){
        if(null==key){
            throw new BusinessException(4001004,"key不能为空");
        }
        return redisTemplate.opsForValue().decrement(key,decrement);
    }
    /**
     *  如果该Key已经存在,APPEND命令将参数Value的数据追加到已存在Value的末尾。如果该Key不存在,
     *  APPEND命令将会创建一个新的Key/Value。返回追加后Value的字符串长度。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param value
     * @return       java.lang.Integer
     * @throws
     */
    public Integer append(String key,String value){
        if(key==null){
            throw new BusinessException(4001004,"key不能为空");
        }
        return redisTemplate.opsForValue().append(key,value);
    }
//******************hash数据类型*********************
    /**
     * 通过key 和 field 获取指定的 value
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param field
     * @return       java.lang.Object
     * @throws
     */
    public Object hget(String key, Object field) {
        if(null==key||null==field){
            return null;
        }
        return redisTemplate.opsForHash().get(key,field);
    }

    /**
     * 为指定的Key设定Field/Value对,如果Key不存在,该命令将创建新Key以用于存储参数中的Field/Value对,
     * 如果参数中的Field在该Key中已经存在,则用新值覆盖其原有值。
     * 返回1表示新的Field被设置了新值,0表示Field已经存在,用新值覆盖原有值。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param field
     * @param value
     * @return
     * @throws
     */
    public void hset(String key, Object field, Object value) {
        if(null==key||null==field){
            return;
        }
        redisTemplate.opsForHash().put(key,field,value);
    }

    /**
     * 判断指定Key中的指定Field是否存在,返回true表示存在,false表示参数中的Field或Key不存在。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param field
     * @return       java.lang.Boolean
     * @throws
     */
    public Boolean hexists(String key, Object field) {
        if(null==key||null==field){
            return false;
        }
        return redisTemplate.opsForHash().hasKey(key,field);
    }

    /**
     * 从指定Key的Hashes Value中删除参数中指定的多个字段,如果不存在的字段将被忽略,
     * 返回实际删除的Field数量。如果Key不存在,则将其视为空Hashes,并返回0。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param fields
     * @return       java.lang.Long
     * @throws
     */
    public Long hdel(String key, Object... fields) {
        if(null==key||null==fields||fields.length==0){
            return 0L;
        }
        return redisTemplate.opsForHash().delete(key,fields);
    }


    /**
     * 通过key获取所有的field和value
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.util.Map<java.lang.Object,java.lang.Object>
     * @throws
     */
    public Map<Object, Object> hgetall(String key) {
        if(key==null){
            return null;
        }
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 逐对依次设置参数中给出的Field/Value对。如果其中某个Field已经存在,则用新值覆盖原有值。
     * 如果Key不存在,则创建新Key,同时设定参数中的Field/Value。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param hash
     * @return
     * @throws
     */
    public void hmset(String key, Map<String, Object> hash) {

        if(null==key||null==hash){
            return;
        }
        redisTemplate.opsForHash().putAll(key,hash);
    }

    /**
     * 获取和参数中指定Fields关联的一组Values,其返回顺序等同于Fields的请求顺序。
     * 如果请求的Field不存在,其值对应的value为null。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param fields
     * @return       java.util.List<java.lang.Object>
     * @throws
     */
    public List<Object> hmget(String key, Collection<Object> fields) {

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

        return redisTemplate.opsForHash().multiGet(key,fields);
    }

    /**
     * 对应key的字段自增相应的值
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param field
     * @param increment
     * @return       java.lang.Long
     * @throws
     */
    public Long hIncrBy(String key,Object field,long increment){
        if (null==key||null==field){
            throw new BusinessException(4001004,"key or field 不能为空");
        }
        return redisTemplate.opsForHash().increment(key,field,increment);

    }
    //***************List数据类型***************
    /**
     * 向列表左边添加元素。如果该Key不存在,该命令将在插入之前创建一个与该Key关联的空链表,之后再将数据从链表的头部插入。
     * 如果该键的Value不是链表类型,该命令将将会抛出相关异常。操作成功则返回插入后链表中元素的数量。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param strs 可以使一个string 也可以使string数组
     * @return       java.lang.Long 返回操作的value个数
     * @throws
     */
    public Long lpush(String key, Object... strs) {
        if(null==key){
            return 0L;
        }
        return redisTemplate.opsForList().leftPushAll(key,strs);
    }

    /**
     * 向列表右边添加元素。如果该Key不存在,该命令将在插入之前创建一个与该Key关联的空链表,之后再将数据从链表的尾部插入。
     * 如果该键的Value不是链表类型,该命令将将会抛出相关异常。操作成功则返回插入后链表中元素的数量。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param strs 可以使一个string 也可以使string数组
     * @return       java.lang.Long 返回操作的value个数
     * @throws
     */
    public Long rpush(String key, Object... strs) {
        if(null==key){
            return 0L;
        }
        return redisTemplate.opsForList().rightPushAll(key,strs);
    }
    /**
     * 返回并弹出指定Key关联的链表中的第一个元素,即头部元素。如果该Key不存在,
     * 返回nil。LPOP命令执行两步操作:第一步是将列表左边的元素从列表中移除,第二步是返回被移除的元素值。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Object
     * @throws
     */
    public Object lpop(String key) {
        if(null==key){
            return null;
        }
        return redisTemplate.opsForList().leftPop(key);
    }

    /**
     * 返回并弹出指定Key关联的链表中的最后一个元素,即头部元素。如果该Key不存在,返回nil。
     * RPOP命令执行两步操作:第一步是将列表右边的元素从列表中移除,第二步是返回被移除的元素值。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Object
     * @throws
     */
    public Object rpop(String key) {
        if(null==key){
            return null;
        }
        return redisTemplate.opsForList().rightPop(key);
    }

    /**
     *该命令的参数start和end都是0-based。即0表示链表头部(leftmost)的第一个元素。
     * 其中start的值也可以为负值,-1将表示链表中的最后一个元素,即尾部元素,-2表示倒数第二个并以此类推。
     * 该命令在获取元素时,start和end位置上的元素也会被取出。如果start的值大于链表中元素的数量,
     * 空链表将会被返回。如果end的值大于元素的数量,该命令则获取从start(包括start)开始,链表中剩余的所有元素。
     * 注:Redis的列表起始索引为0。显然,LRANGE numbers 0 -1 可以获取列表中的所有元素。返回指定范围内元素的列表。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param start
     * @param end
     * @return       java.util.List<java.lang.Object>
     * @throws
     */
    public List<Object> lrange(String key, long start, long end) {
        if(null==key){
            return null;
        }
        return redisTemplate.opsForList().range(key,start,end);
    }

    /**
     * 让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。
     * 下标 0 表示列表的第一个元素,以 1 表示列表的第二个元素,以此类推。
     * 你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param start
     * @param end
     * @return
     * @throws
     */
    public void ltrim(String key, long start, long end) {
        if(null==key){
            return;
        }
        redisTemplate.opsForList().trim(key,start,end);
    }

    /**
     * 该命令将返回链表中指定位置(index)的元素,index是0-based,表示从头部位置开始第index的元素,
     * 如果index为-1,表示尾部元素。如果与该Key关联的不是链表,该命令将返回相关的错误信息。 如果超出index返回这返回nil。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param index
     * @return       java.lang.Object
     * @throws
     */
    public Object lindex(String key, long index) {
        if(null==key){
            return null;
        }
        return redisTemplate.opsForList().index(key,index);
    }

    /**
     * 返回指定Key关联的链表中元素的数量,如果该Key不存在,则返回0。如果与该Key关联的Value的类型不是链表,则抛出相关的异常。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Long
     * @throws
     */
    public Long llen(String key) {

        if(null==key){
            return 0L;
        }
        return redisTemplate.opsForList().size(key);
    }
    //***************Set数据类型*************
    /**
     * 如果在插入的过程用,参数中有的成员在Set中已经存在,该成员将被忽略,而其它成员仍将会被正常插入。
     * 如果执行该命令之前,该Key并不存在,该命令将会创建一个新的Set,此后再将参数中的成员陆续插入。返回实际插入的成员数量。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param members 可以是一个String 也可以是一个String数组
     * @return       java.lang.Long 添加成功的个数
     * @throws
     */
    public Long sadd(String key, Object... members) {
        if (null==key){
            return 0L;
        }
        return redisTemplate.opsForSet().add(key, members);

    }

    /**
     * 返回Set中成员的数量,如果该Key并不存在,返回0。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Long
     * @throws
     */
    public Long scard(String key) {
        if (null==key){
            return 0L;
        }
        return redisTemplate.opsForSet().size(key);

    }

    /**
     * 判断参数中指定成员是否已经存在于与Key相关联的Set集合中。返回true表示已经存在,false表示不存在,或该Key本身并不存在。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param member
     * @return       java.lang.Boolean
     * @throws
     */
    public Boolean sismember(String key, Object member) {
        if (null==key){
            return false;
        }
        return redisTemplate.opsForSet().isMember(key,member);

    }

    /**
     * 和SPOP一样,随机的返回Set中的一个成员,不同的是该命令并不会删除返回的成员。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.String
     * @throws
     */
    public Object srandmember(String key) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForSet().randomMember(key);

    }
    /**
     * 和SPOP一样,随机的返回Set中的一个成员,不同的是该命令并不会删除返回的成员。
     * 还可以传递count参数来一次随机获得多个元素,根据count的正负不同,具体表现也不同。
     * 当count 为正数时,SRANDMEMBER 会随机从集合里获得count个不重复的元素。
     * 如果count的值大于集合中的元素个数,则SRANDMEMBER 会返回集合中的全部元素。
     * 当count为负数时,SRANDMEMBER 会随机从集合里获得|count|个的元素,如果|count|大与集合中的元素,
     * 就会返回全部元素不够的以重复元素补齐,如果key不存在则返回nil。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param count
     * @return       java.util.List<java.lang.String>
     * @throws
     */
    public List<Object> srandmember(String key,int count) {
        if(null==key){
            return null;
        }
        return redisTemplate.opsForSet().randomMembers(key,count);

    }

    /**
     * 通过key随机删除一个set中的value并返回该值
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.String
     * @throws
     */
    public Object spop(String key) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForSet().pop(key);

    }

    /**
     * 通过key获取set中所有的value
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.util.Set<java.lang.String>
     * @throws
     */
    public Set<Object> smembers(String key) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForSet().members(key);

    }
    /**
     * 从与Key关联的Set中删除参数中指定的成员,不存在的参数成员将被忽略,
     * 如果该Key并不存在,将视为空Set处理。返回从Set中实际移除的成员数量,如果没有则返回0。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param members
     * @return       java.lang.Long
     * @throws
     */
    public Long srem(String key, Object... members) {
        if (null==key){
            return 0L;
        }
        return redisTemplate.opsForSet().remove(key,members);

    }

    /**
     * 将元素value从一个集合移到另一个集合
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param srckey
     * @param dstkey
     * @param member
     * @return       java.lang.Long
     * @throws
     */
    public Boolean smove(String srckey, String dstkey, Object member) {
        if (null==srckey||null==dstkey){
            return false;
        }
        return redisTemplate.opsForSet().move(srckey,member,dstkey);

    }


    /**
     * 获取两个集合的并集
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param otherKeys
     * @return       java.util.Set<java.lang.Object> 返回两个集合合并值
     * @throws
     */
    public Set<Object> sUnion(String key, String otherKeys) {
        if (null==key||otherKeys==null){
            return null;
        }
        return redisTemplate.opsForSet().union(key, otherKeys);
    }
    //**********Sorted Set 数据类型********************
    /**
     *添加参数中指定的所有成员及其分数到指定key的Sorted Set中,在该命令中我们可以指定多组score/member作为参数。
     * 如果在添加时参数中的某一成员已经存在,该命令将更新此成员的分数为新值,同时再将该成员基于新值重新排序。
     * 如果键不存在,该命令将为该键创建一个新的Sorted Set Value,并将score/member对插入其中。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param score
     * @param member
     * @return       java.lang.Long
     * @throws
     */
    public Boolean zadd(String key, double score, Object member) {
        if (null==key){
            return false;
        }
        return redisTemplate.opsForZSet().add(key,member,score);

    }


    /**
     * 该命令将移除参数中指定的成员,其中不存在的成员将被忽略。
     * 如果与该Key关联的Value不是Sorted Set,相应的错误信息将被返回。 如果操作成功则返回实际被删除的成员数量。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param members 可以使一个string 也可以是一个string数组
     * @return       java.lang.Long
     * @throws
     */
    public Long zrem(String key, Object... members) {
        if(null==key||null==members){
            return 0L;
        }
        return redisTemplate.opsForZSet().remove(key,members);

    }

    /**
     * 返回Sorted Set中的成员数量,如果该Key不存在,返回0。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @return       java.lang.Long
     * @throws
     */
    public Long zcard(String key) {
        if (null==key){
            return 0L;
        }
        return redisTemplate.opsForZSet().size(key);
    }

    /**
     * 该命令将为指定Key中的指定成员增加指定的分数。如果成员不存在,该命令将添加该成员并假设其初始分数为0,
     * 此后再将其分数加上increment。如果Key不存在,该命令将创建该Key及其关联的Sorted Set,
     * 并包含参数指定的成员,其分数为increment参数。如果与该Key关联的不是Sorted Set类型,
     * 相关的错误信息将被返回。如果不报错则以串形式表示的新分数。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param score
     * @param member
     * @return       java.lang.Double
     * @throws
     */
    public Double zincrby(String key, double score, Object member) {
        if (null==key){
            throw new BusinessException(4001004,"key 不能为空");
        }
        return redisTemplate.opsForZSet().incrementScore(key,member,score);
    }

    /**
     * 该命令用于获取分数(score)在min和max之间的成员数量。
     * (min=<score<=max)如果加上了“(”着表明是开区间例如zcount key (min max 则 表示(min<score=<max)
     * 同理zcount key min (max 则表明(min=<score<max) 返回指定返回数量。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param min
     * @param max
     * @return       java.lang.Long
     * @throws
     */
    public Long zcount(String key, double min, double max) {
        if (null==key){
            return 0L;
        }
        return redisTemplate.opsForZSet().count(key, min, max);

    }

    /**
     * Sorted Set中的成员都是按照分数从低到高的顺序存储,该命令将返回参数中指定成员的位置值,
     * 其中0表示第一个成员,它是Sorted Set中分数最低的成员。 如果该成员存在,则返回它的位置索引值。否则返回nil。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param member
     * @return       java.lang.Long
     * @throws
     */
    public Long zrank(String key, Object member) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForZSet().rank(key,member);

    }

    /**
     * 如果该成员存在,以字符串的形式返回其分数,否则返回null
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param member
     * @return       java.lang.Double
     * @throws
     */
    public Double zscore(String key, Object member) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForZSet().score(key,member);
    }

    /**
     * 该命令返回顺序在参数start和stop指定范围内的成员,这里start和stop参数都是0-based,即0表示第一个成员,-1表示最后一个成员。如果start大于该Sorted
     * Set中的最大索引值,或start > stop,此时一个空集合将被返回。如果stop大于最大索引值,
     * 该命令将返回从start到集合的最后一个成员。如果命令中带有可选参数WITHSCORES选项,
     * 该命令在返回的结果中将包含每个成员的分数值,如value1,score1,value2,score2...。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param min
     * @param max
     * @return       java.util.Set<java.lang.String> 指定区间内的有序集成员的列表。
     * @throws
     */
    public Set<Object> zrange(String key, long min, long max) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForZSet().range(key, min, max);

    }
    /**
     * 该命令的功能和ZRANGE基本相同,唯一的差别在于该命令是通过反向排序获取指定位置的成员,
     * 即从高到低的顺序。如果成员具有相同的分数,则按降序字典顺序排序。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param start
     * @param end
     * @return       java.util.Set<java.lang.String>
     * @throws
     */
    public Set<Object> zReverseRange(String key, long start, long end) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForZSet().reverseRange(key, start, end);

    }

    /**
     * 该命令将返回分数在min和max之间的所有成员,即满足表达式min <= score <= max的成员,
     * 其中返回的成员是按照其分数从低到高的顺序返回,如果成员具有相同的分数,
     * 则按成员的字典顺序返回。可选参数LIMIT用于限制返回成员的数量范围。
     * 可选参数offset表示从符合条件的第offset个成员开始返回,同时返回count个成员。
     * 可选参数WITHSCORES的含义参照ZRANGE中该选项的说明。*最后需要说明的是参数中min和max的规则可参照命令ZCOUNT。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param max
     * @param min
     * @return       java.util.Set<java.lang.String>
     * @throws
     */
    public Set<Object> zrangebyscore(String key, double min, double max) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForZSet().rangeByScore(key, min, max);

    }



    /**
     * 该命令除了排序方式是基于从高到低的分数排序之外,其它功能和参数含义均与ZRANGEBYSCORE相同。
     * 需要注意的是该命令中的min和max参数的顺序和ZRANGEBYSCORE命令是相反的。
     * @Author:       
     * @UpdateUser:
     * @Version:     0.0.1
     * @param key
     * @param max
     * @param min
     * @return       java.util.Set<java.lang.String>
     * @throws
     */
    public Set<Object> zrevrangeByScore(String key, double min, double max) {
        if (null==key){
            return null;
        }
        return redisTemplate.opsForZSet().reverseRangeByScore(key, min, max);
    }
}

企业级规范化开发:前后端分离数据封装 DataResult

在前后端分离操作时,我们必须要有一个统一的数据格式来传参,这个格式应该是和前端商量好的结果。

但是我们目前使用的是String传参,即后端数据的返回均使用的是JSON字符串的格式,故而此处我们将DataResult这个数据格式封装后,仍然是使用fastJSON将其转换为JSON格式,在对其返回给前端。

在这个类中我们使用泛型来实现对不同参数的封装

package com.wz.lesson.utils;

import com.wz.lesson.exception.code.BaseResponseCode;
import com.wz.lesson.exception.code.ResponseCodeInterface;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

// 此类实际开发中是否使用有待考究
// 可以使用在JWT中学习到的返还前端数据的方法来替代
/**
 * 前后端分离数据封装 DataResult
 * 前后端分离情况下,反馈数据(状态码、提示语、数据)格式的统一,数据封装到DataResult
 * 这里使用泛型T是为了使不同的反馈数据均可传入
 *
 * 目前市面上公司开发模式普遍采用了前后端分离,而前后端交互一般会以 json 的形式交互,既然涉及到多方交互那就需要一些约定好
 * 的交互格式,然而每个人的想法有可能是不一样的,所以定义的格式字段就可能不一样,如果我们后端不统一前端会不知道应该怎么操作,
 * 所以我们需要封装一个统一的返回格式
 */
@Data
public class DataResult<T> {
    //状态码
    @ApiModelProperty(value = "状态码,0成功,其他失败")
    private int code;
    // 响应
    @ApiModelProperty(value = "提示语")
    private String msg;
    // 反馈数据
    @ApiModelProperty(value = "反馈数据")
    private T data;

    public DataResult(int code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public DataResult(int code, T data) {
        this.code = code;
        this.data = data;
        this.msg = null;
    }

    public DataResult(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public DataResult(){
        this.code = BaseResponseCode.SUCCESS.getCode();
        this.msg=BaseResponseCode.SUCCESS.getMsg();
    }

    public DataResult(T data){
        this.code = BaseResponseCode.SUCCESS.getCode();
        this.msg=BaseResponseCode.SUCCESS.getMsg();
        this.data = data;
    }

    public static DataResult success(){
        return new DataResult();
    }

    public static <T> DataResult success(T data){
        return new DataResult(data);
    }


    // 传入接口对象取得code和msg的构造方法
    public DataResult(ResponseCodeInterface responseCodeInterface, T data){
        this.code = responseCodeInterface.getCode();
        this.msg = responseCodeInterface.getMsg();
        this.data = data;
    }

    public DataResult(ResponseCodeInterface responseCodeInterface){
        this.code = responseCodeInterface.getCode();
        this.msg = responseCodeInterface.getMsg();
        this.data = null;
    }

    //不同参数个数情况下的构造方法生成的对象通过不同的静态方法返回
    public static <T> DataResult getResult(int code,String msg,T data){
        return new DataResult(code,msg,data);
    }

    public static DataResult getResult(int code,String msg){
        return new DataResult(code,msg);
    }

    public static <T> DataResult getResult(int code,T data){
        return new DataResult(code,data);
    }

    // 静态方法
    public static <T> DataResult getResult(ResponseCodeInterface responseCodeInterface,T data){
        return new DataResult(responseCodeInterface,data);
    }

    public static DataResult getResult(ResponseCodeInterface responseCodeInterface){
        return new DataResult(responseCodeInterface);
    }

}

企业级规范化开发:封装统一的相应 状态码code 工具类

package com.wz.lesson.exception.code;

// 此接口实际开发中是否使用有待考究
public interface ResponseCodeInterface {
    // 取得code
    int getCode();
    // 取得msg
    String getMsg();
}

实现接口的枚举类

package com.wz.lesson.exception.code;

/**
 * 自定义异常错误状态码及反馈信息的枚举类
 * 如果还有其他需要提示的异常,再继续在枚举中添加即可
 */
// 枚举类
public enum BaseResponseCode implements ResponseCodeInterface {
    /**
     * 这个要和前段约定好
     *code=0:服务器已成功处理了请求。 通常,这表示服务器提供了请求的网页。
     *code=401 0001:(授权异常) 请求要求身份验证。 客户端需要跳转到登录页面重新登录
     *code=401 0002:(凭证过期) 客户端请求刷新凭证接口
     *code=403 0001:没有权限禁止访问
     *code=400 xxxx:系统主动抛出的业务异常
     *code=500 0001:系统异常
     */
    // 操作成功的操作
    SUCCESS(0,"操作成功"),
    // 服务器内部异常操作
    SYSTEM_ERROR(5000001,"系统异常,500。"),
    DATA_ERROR(4000001,"参数异常"),
    VALID_DATA_ERROR(4000002,"参数校验异常"),
    USER_ERROR(4000003,"账号异常,请重新注册。"),
    USER_LOCK_ERROR(4000004,"该账号涉嫌违规,已被封禁,用户已被强制登出。"),
    USER_PASSWORD_ERROR(4000005,"账号密码错误。"),
    TOKEN_NOT_NULL_ERROR(4010001,"Token凭证不能为空,请重新登录获取。"),
    TOKEN_LOSE_ERROR(4010001,"Token认证失败,请重新登录获取。。"),
    ACCOUNT_LOCK(4010001,"该账号被锁定,请联系系统管理员"),
    ACCOUNT_HAS_DELETED_ERROR(4010001,"该账号已被删除,请联系系统管理员"),
    TOKEN_PAST_DUE(4010002,"token失效,请刷新token"),
    NOT_PERMISSION(4030001,"没有权限访问该资源"),
    ;


    // 状态码
    private final int code;

    // 提示语
    private final String msg;

    BaseResponseCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    @Override
    public int getCode() {
        return code;
    }

    @Override
    public String getMsg() {
        return msg;
    }
}

创建 测试 DataResult 接口

@GetMapping("/home")
    @ApiOperation(value = "测试DataResult接口")
    public DataResult<String> getHome(){
        DataResult<String> result=DataResult.success(" 测试成功 ");
        return result;
   }

解决 spring boot devtool 热部署后出现访问 404 问题
DevTools的检测时间和idea的编译所需时间存在差异。在idea还没完成编译工作前,DevTools就开始进行重启和加载,导致 @RequestMapping没有被全部正常处理。其他方法没试,就直接用了看起来最简单的方法:牺牲一点时间,去加长devtools的轮询时 间,增大等待时间。 解决方案如下:

配置文件中加入如下

# 解决 spring boot devtool 热部署后出现访问 404 问题
#DevTools的检测时间和idea的编译所需时间存在差异。在idea还没完成编译工作前,DevTools就开始进行重启和加载,导致
#@RequestMapping没有被全部正常处理。其他方法没试,就直接用了看起来最简单的方法:牺牲一点时间,去加长devtools的轮询时
#间,增大等待时间。
#解决方案如下:
spring.devtools.restart.poll-interval=3000ms
spring.devtools.restart.quiet-period=2999ms
spring.devtools.restart.poll-interval=3000ms spring.devtools.restart.quiet-period=2999ms

企业级规范化开发:全局异常统一处理

企业级规范化开发:全局异常监听类RestExceptionHandler

此类的作用是:全局异常处理,想对指定的异常进行自定义提示和状态码,只需要在此类中进行添加即可,添加完成后,自定义的提示内容就会替代默认内容,比如空指针异常提示NullPointException:null,可以被我们自定义为NullPointException:出现空指针异常。将后端的各种异常信息包装成统一的格式,避免异常详细内容直接抛给前端使用了统一异常格式,服务器端就相当于对异常进行了处理,所以默认状态码就为200,但是我们使用DataResult对返回数据进行统一处理,时返回我们自定义的状态码500,前端取得的是我们自定义的状态码。

创建一个 RestExceptionHandler 类 加上 @ControllerAdvice 注解
package com.yingxue.lesson.exception.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
@Slf4j
public class RestExceptionHandler {
}
@ExceptionHandler 声明一个异常处理的方法
/**
* 系统繁忙,请稍候再试
*/
    @ExceptionHandler(Exception.class)
    public <T> DataResult<T> handleException(Exception e){
        log.error("Exception,exception:{}", e);
        return DataResult.getResult(BaseResponseCode.SYSTEM_BUSY);
   }

测试:在TestController中加入此方法

 @GetMapping("/home")
    @ApiOperation(value = "测试DataResult接口")
    public DataResult<String> getHome(){
        int i=1/0; // 测试异常
        DataResult<String> result=DataResult.success("哈哈哈哈测试成功 欢迎来到迎学教育");
        return result;
   }

企业级规范化开发:开始书写自定义万金油异常信息抛出类BusinessException

此类相当于应该工具类,用于将指定的异常信息、异常状态码按照我们自定义的格式进行抛出。

此类的逻辑是:将BaseResponseCode类中的静态常量取得并封装进来,此常量就是我们设置的状态码和返回提示语

例:throw new BusinessException(BaseResponseCode.TOKEN_NOT_NULL_ERROR);

在需要抛出的地方直接书写上面语句,比如上面为TOKEN_NOT_NULL_ERROR的常量代表Token不能为空的情况。

一般在try/catch中的catch中使用,因为已经catch处理,故而在反馈数据给前端时,请求尾response中的状态码是200,但是前端应该依据BusinessException返回的状态码,即在response返回请求带回的请求体body中的状态码code

package com.wz.lesson.exception;

import com.wz.lesson.exception.code.ResponseCodeInterface;
/**
 * 自定义的业务逻辑异常类
 在现实实战中,往往我们会在复杂的带有数据库事务的业务中,经常会遇到一些不规则的信息,这个就需要我们后端根据相应的业务
抛出相应的运行时异常,进行数据库事务回滚,并希望该异常信息能被返回显示给用户。
 */
public class BusinessException extends RuntimeException {

    /**
     * 提示的编码
     */
    private final int code;

    /**
     * 提示语
     */
    private final String msg;

    public BusinessException(int code, String msg) {
        super(msg); // 将msg传入父类构造方法
        this.code = code;
        this.msg = msg;
    }

    public BusinessException(ResponseCodeInterface responseCodeInterface){
        this(responseCodeInterface.getCode(),responseCodeInterface.getMsg());// 此处this的作用是调用本类中其他构造方法 // 此处是调用上面的参数为code和msg的构造方法
    }

/*    public BusinessException(ResponseCodeInterface responseCodeInterface) {
        this(responseCodeInterface.getCode(),responseCodeIntejavarface.getMsg());
    }*/

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

测试:在TestController中添加如下方法

@GetMapping("/business/error")
    @ApiOperation(value = "测试主动抛出业务异常接口")
    public DataResult<String> testBusinessError(@RequestParam String type){
        if(!(type.equals("1")||type.equals("2")||type.equals("3")){
            throw new BusinessException(BaseResponseCode.DATA_ERROR);
       }
        DataResult<String> result=new DataResult(0,type);
        return result;
   }

企业级规范化开发:使用Hibernate Validator框架实现参数校验

平时项目中,难免需要对参数 进行一些参数正确性的校验,这些校验出现在业务代码中,让我们的业务代码显得臃肿,而且,频繁的编写这类参数校验代码很无聊。鉴于此,觉得 Hibernate Validator 框架刚好解决了这些问题,可以很优雅的方式实现参数的校验,让业务代码和校验逻辑分开,不再编写重复的校验逻辑。
Hibernate Validator 的作用
验证逻辑与业务逻辑之间进行了分离,降低了程序耦合度;
统一且规范的验证方式,无需你再次编写重复的验证代码;
你将更专注于你的业务,将这些繁琐的事情统统丢在一边。
Hibernate Validator 的使用
项目中,主要用于接口api 的入参校验和 封装工具类 在代码中校验两种使用方式。
常用的注解
@NotEmpty 用在集合类上面
@NotBlank 用在String上面
@NotNull 用在基本数据类型上
@Valid:启用校验

测试使用

package com.wz.lesson.vo.request;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;

/**
 * 使用Hibernate Validator对 输入 参数进行校验
 */
@Data
public class TestRequestVO {
    /**
     * @NotEmpty 用在集合类上面
     * @NotBlank 用在String上面
     * @NotNull 用在基本数据类型上
     * @Valid:启用校验
     */
    @NotEmpty(message = "list数据不能为空")
    @ApiModelProperty(value = "list 集合数据")
    private List<String> list;

    @NotBlank(message = "用户名不能为空")
    @ApiModelProperty(value = "String数据")
    private String username;

    @NotNull(message = "年龄不能为空")
    @ApiModelProperty(value = "基础类型数据")
    private Integer age; // 注意此处必须是Integer,即必须是基础数据类型对应的包装类
}

企业级规范化开发:什么是VO层?VO层的作用是什么?

/**
 *
 * 前端传入值方向的VO
 *
 * vo 里的每一个字段,是和前台 html 页面相对应
 *
 * VO即value object值对象
 * 主要体现在视图的对象,对于一个WEB页面将整个页面的属性封装成一个对象。然后用一个VO对象在控制层与视图层进行传输交换。
 *
 * DTO (经过处理后的PO,可能增加或者减少PO的属性):
 * Data Transfer Object数据传输对象
 * 主要用于远程调用等需要大量传输对象的地方。
 * 比如我们一张表有100个字段,那么对应的PO就有100个属性。
 * 但是我们界面上只要显示10个字段,
 * 客户端用WEB service来获取数据,没有必要把整个PO对象传递到客户端,
 * 这时我们就可以用只有这10个属性的DTO来传递结果到客户端,这样也不会暴露服务端表结构.到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为VO。
 */

JWT 工具类封装

此工具类进行Token生成、验证、刷新、超时判断等操作

我们在此单点登录安全框架中整合的是JWT的Token生成、验证、双Token刷新功能,又因为双Token刷新的存在,并没有真正贯彻JWT不在服务端存储认证信息的理念,因为我们将refreshToken存在了Redis中。

JwtTokenUtil 工具类创建

import lombok.extern.slf4j.Slf4j;
@Slf4j
public class JwtTokenUtil {
}

JWT 相关配置和属性注入

# JWT配置
#JWT 密钥
jwt.secretKey=78944878877848fg)
# 以下是JWT双token刷新配置
# 使用双Token刷新的意义是:
# 避免单token情况下活跃用户需要反复登录的问题,用户登录后生成access_token 和 refresh_token两个token,access_token刷新时间短最多几小时,refresh_token刷新时间长可以达几天,access_token是用户保持登录状态使用的token,
# 而refresh_token是access_token过期后客户端根据此refresh_token来获取一个新的access_token来实现避免频繁的重复登录,当长时间有效的refresh_token过期后才需要重新登录一次,然后又可以长时间使用。
# 用户不活跃access_token不久就过期了,因为已经不再使用,用户端也不会主动通过refresh_token来获取新的access_token,这样就避免了用户退出后,恶意攻击者盗取token来盗取用户账号。
# 第一次用账号密码登录服务器会返回两个 token : access_token 和 refresh_token,时效长短不一样。短的access_token 时效过了之后,发送时效长的 refresh_token 重新获取一个短时效token,如果都过期,就需要重新登录了。
# refresh_token 就是用来刷新access_token 。活跃用户的 access_token 过期了,用refresh_token 获取新的access_token 。
# 由此可见refresh_token的过期时间就是用户保持登录的最长时间
# 业务短时间刷新Token过期时间 2小时
jwt.accessTokenExpireTime=PT2H
# PC端长时间刷新Token自动刷新时间 8小时
jwt.refreshTokenExpireTime=PT8H
# APP端长时间刷新Token自动刷新时间 30天
jwt.refreshTokenExpireAppTime=P30D
# 发行人
jwt.issuer=wz.org.cn
企业级规范化开发:创建JWT配置文件中变量读取类
package com.wz.lesson.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

/**
 * JWT配置类
 */
@Configuration
@ConfigurationProperties(prefix = "jwt") // 将Properties配置文件中以jwt开头的字段注入进来,自动根据本类静态成员变量的变量名进行注入(赋值)
@Data
public class TokenSetting {
    // 注意此类以下成员变量变量名必须和Properties配置文件中jwt开头的字段的变量名一致
    private String secretKey;
    private Duration accessTokenExpireTime;
    private Duration refreshTokenExpireTime;
    private Duration refreshTokenExpireAppTime;
    private String issuer;
}
企业级规范化开发:创建初始化配置代理类
package com.wz.lesson.utils;

import com.wz.lesson.config.TokenSetting;
import org.springframework.stereotype.Component;

/**
 * JWT初始化代理类
 * JWT配置类 与 工具类桥梁
 */
@Component
public class InitializerUtil {

    // 将JWT配置类中的默认参数值 通过SET方法 注入到JWT工具类中
    public InitializerUtil(TokenSetting tokenSetting) {
        JwtTokenUtil.setJwtProperties(tokenSetting);
    }
}

在JwtTokenUtil中配置工具类中常用操作token业务的静态方法

package com.wz.lesson.utils;

import com.wz.lesson.config.TokenSetting;
import com.wz.lesson.constant.Constant;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;

import javax.xml.bind.DatatypeConverter;
import java.time.Duration;
import java.util.Date;
import java.util.Map;

/**
 * JWT工具类
 */
@Slf4j
public class JwtTokenUtil {
    /**
     * # JWT 密钥
     * # 业务短时间刷新Token过期时间
     * # PC端长时间刷新Token自动刷新时间
     * # APP端长时间刷新Token自动刷新时间
     * # 发行人
     * 我们也可以不将以上数据写在配置文件中,可以直接写在此类中作为此类的成员静态常量,或者独立一个公共静态常量类,另外一种jwt写法详见工程Q:\JWT_Login_About\jwt_01
     */
    private static String secretKey;
    private static Duration accessTokenExpireTime;
    private static Duration refreshTokenExpireTime;
    private static Duration refreshTokenExpireAppTime;
    private static String issuer;

    // 初始化JWT的方法 // 取得JWT配置类中取得的配置文件中的默认值
    public static void setJwtProperties(TokenSetting tokenSetting) {
        secretKey = tokenSetting.getSecretKey();
        accessTokenExpireTime = tokenSetting.getAccessTokenExpireTime();
        refreshTokenExpireTime = tokenSetting.getRefreshTokenExpireTime();
        refreshTokenExpireAppTime = tokenSetting.getRefreshTokenExpireAppTime();
        issuer = tokenSetting.getIssuer();
    }


    /**
     * 生成短时间刷新access_token
     * @param subject 用户ID
     * @param claims 载荷部分参数,即此用户的个人信息
     * @return
     */
    public static String getAccessToken(String subject, Map<String, Object> claims) {
        return generateToken(issuer, subject, claims, accessTokenExpireTime.toMillis(), secretKey);
    }

    /**
     * 此方法需要详细解读
     * 签发token
     * @param issuer    签发人
     * @param subject   代表这个JWT的主体,即它的所有人 一般是用户id
     * @param claims    存储在JWT里面的信息 一般放些用户的权限/角色信息
     * @param ttlMillis 有效时间(毫秒)
     */
    public static String generateToken(String issuer, String subject, Map<String, Object> claims, long ttlMillis, String secret) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        byte[] signingKey = DatatypeConverter.parseBase64Binary(secret);
        JwtBuilder builder = Jwts.builder();
        builder.setHeaderParam("type", "JWT");
        if (null != claims) {
            builder.setClaims(claims);
        }
        if (!StringUtils.isEmpty(subject)) {
            builder.setSubject(subject);
        }
        if (!StringUtils.isEmpty(issuer)) {
            builder.setIssuer(issuer);
        }
        builder.setIssuedAt(now);
        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);
        }
        builder.signWith(signatureAlgorithm, signingKey);
        return builder.compact();
    }


    // 配置签发长时间刷新token的静态方法
    // 上面我们已经有生成 access_token 的方法,下面加入生成 refresh_token 的方法(PC 端过期时间短一些)
    /**
     * 生产PC端长时间刷新refresh_token
     */
    public static String getRefreshToken(String subject,Map<String,Object> claims){
        return generateToken(issuer,subject,claims,refreshTokenExpireTime.toMillis(),secretKey);
    }


    //上面我们已经有生成 access_token 的方法,下面加入生成 refresh_token 的方法(APP 端过期时间长一些)
    /**
     * 生产App端长时间刷新refresh_token
     */
    public static String getRefreshAppToken(String subject,Map<String,Object> claims){
        return generateToken(issuer,subject,claims,refreshTokenExpireAppTime.toMillis(),secretKey);
    }

    /**
     * 从令牌负载中获取数据
     * 解析token获取数据的静态方法
     */
    public static Claims getClaimsFromToken(String token) {
        Claims claims = null;
        try {
            claims = Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            if(e instanceof ClaimJwtException){
                claims=((ClaimJwtException) e).getClaims();
            }
        }
        return claims;
    }

    /**
     * 获取用户id
     * 通过token 获取userId 静态方法
     */
    public static String getUserId(String token){
        String userId=null;
        try {
            // 取得负载内容
            Claims claims = getClaimsFromToken(token);
            // 从负载内容中取得UserID
            userId = claims.getSubject();
        } catch (Exception e) {
            log.error("error={}",e);
        }
        return userId;
    }

    /**
     * 获取用户名
     */
    public static String getUserName(String token){
        String username=null;
        try {
            // 取得负载内容
            Claims claims = getClaimsFromToken(token);
            // 使用用户名对应的key名,取得负责内容中的用户名值
            username = (String) claims.get(Constant.JWT_USER_NAME);
        } catch (Exception e) {
            log.error("error={}",e);
        }
        return username;
    }

    /**
     *  token 是否过期方法
     * 校验令牌(true:未过期 false:已经过期)
     * @param token
     * @return
     */
    public static Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            // Date1.before(Date2),当Date1小于Date2时,返回TRUE,当大于等于时,返回false
            // 当expiration大于new Date()时,即过期时间大于当前时间,返回false
            boolean before = expiration.before(new Date()); // 显然此处返回false
            return before;
        } catch (Exception e) {
            log.error("error={}",e);
//            return false;
            return true; // 此处为true的原因不明
        }
    }

    /**
     * 验证token是否有效的方法
     * 校验令牌(true:验证通过 false:验证失败)
     * @param token
     * @return
     */
    public static Boolean validateToken(String token) {
        // 取得Token解析出来的数据
        Claims claimsFromToken = getClaimsFromToken(token);
        //果解析出来的数据是否为空 且 Token是否已经过期
        // !isTokenExpired(token)意思是如果token在有效期择返回false的取反true
        return (null!=claimsFromToken && !isTokenExpired(token));
    }

    /**
     * 获取token的剩余过期时间方法
     * 获取token的剩余过期时间
     * @param token
     * @return
     */
    public static long getRemainingTime(String token){
        long result=0;
        try {
            long nowMillis = System.currentTimeMillis();
            result= getClaimsFromToken(token).getExpiration().getTime()-nowMillis;
        } catch (Exception e) {
            log.error("error={}",e);
        }
        return result;
    }
}

企业级规范化开发:创建公有的静态常量类Constant

此类中的静态常量均是要用再新增,此类经过使用已经比较完善,将来可以直接使用。

此类中的静态常量均是使用频率高的key,这些key常常被用作:Redis、请求头/尾、Map集合中。

package com.wz.lesson.constant;

/**
 *  公有的静态常量类
 *  用于封装常用的数据
 */
public class Constant {
    /**
     * Constant 加入 用户名 key 常量
     * 用户名在Token中的key名,为jwt-user-name-key
     */
    public static final String JWT_USER_NAME="jwt-user-name-key";

    /**
     * 角色信息Key
     */
    public static final String ROLES_INFOS_KEY="roles-infos-key";

    /**
     * 权限信息Key
     */
    public static final String POWER_KEY="power-key";

    /**
     * 业务访问Token accessToken
     */
    public static final String ACCESS_TOKEN = "authorization";

    /**
     * 主动去刷新 token key(适用场景 比如修改了用户的角色/权限去刷新token)
     */
    public static final String JWT_REFRESH_KEY="jwt-refresh-key_";

    /**
     * 标记用户是否已经被锁定
      */
    public static final String ACCOUNT_LOCK_KEY="account-lock-key_";

    /**
     * 标记用户是否已经删除
     */
    public static final String DELETED_USER_KEY="deleted-user-key_";

    /**
     * 用户权鉴缓存 key
     */
    public static final String IDENTIFY_CACHE_KEY="shirocache:com.wz.lesson.shiro.CustomRealm.authorizationCache:";

}

主流联合单点登录安全框架正式搭建–Spring Boot+Shiro+JWT+redis 前后端分离脚手架

实现用户认证签发 token

首先用户登录进来,我们先验证用户名/密码,验证通过后我们会生成两个token(access_token和refresh_token 他们的区别是一个过
期时间短一个过期时间长。
access_Token 携带了拥有的角色信息和权限信息)然后把一些必要的参数封装成 LoginRespVO 响应回客户端。
token 主要包含 用户id、用户登录名、用户所拥有的角色(这里我们先写 mock 数据)、用户所拥有的权限(这里我们先写 mock 数据)、和签发单位标识。
需要用的类说明:

LoginReqVO - 接收客户端表单提交数据
LoginRespVO - 响应客户端数据
UserController.java – 控制层
UserService.java & UserServiceImpl.java – 服务层
UserMapper.java & UserMapper.xml – 数据访问层
PasswordEncoder & PasswordUtils - 密码校验工具类

执行以下SQL语句用于写入测试数据:

INSERT INTO `sys_user` (`id`, `username`, `salt`, `password`, `phone`, `dept_id`, `real_name`,
`nick_name`, `email`, `status`, `sex`, `deleted`, `create_id`, `update_id`, `create_where`,
`create_time`, `update_time`) VALUES ('9a26f5f1-cbd2-473d-82db-1d6dcf4598f8', 'admin',
'324ce32d86224b00a02b', 'ac7e435db19997a46e3b390e69cb148b', '13888888888', '24f41c71-5a95-4ef4-9493-
174574f3b0c5', NULL, NULL, 'yingxue@163.com', '1', NULL, '1', NULL, NULL, '3', '2019-09-22 19:38:05',
NULL);
INSERT INTO `sys_user` (`id`, `username`, `salt`, `password`, `phone`, `dept_id`, `real_name`,
`nick_name`, `email`, `status`, `sex`, `deleted`, `create_id`, `update_id`, `create_where`,
`create_time`, `update_time`) VALUES ('9a26f5f1-cbd2-473d-82db-1d6dcf4598f4', 'dev123',
'324ce32d86224b00a02b', 'ac7e435db19997a46e3b390e69cb148b', '13666666666', '24f41c71-5a95-4ef4-9493-
174574f3b0c5', NULL, NULL, 'yingxue@163.com', '1', NULL, '1', NULL, NULL, '3', '2019-09-22 19:38:05',
NULL);

其中密码均为666666加盐加密后的值,在Swagger或者Postman等进行测试时直接使用666666作为参数传入即可

企业级规范化开发:LoginReqVO

package com.wz.lesson.vo.request;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
 *
 * 前端传入值方向的VO
 *
 * vo 里的每一个字段,是和你前台 html 页面相对应
 * VO即value object值对象
 * 主要体现在视图的对象,对于一个WEB页面将整个页面的属性封装成一个对象。然后用一个VO对象在控制层与视图层进行传输交换。
 * DTO (经过处理后的PO,可能增加或者减少PO的属性):
 * Data Transfer Object数据传输对象
 * 主要用于远程调用等需要大量传输对象的地方。
 * 比如我们一张表有100个字段,那么对应的PO就有100个属性。
 * 但是我们界面上只要显示10个字段,
 * 客户端用WEB service来获取数据,没有必要把整个PO对象传递到客户端,
 * 这时我们就可以用只有这10个属性的DTO来传递结果到客户端,这样也不会暴露服务端表结构.到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为VO。
 */
@Data
public class LoginReqVO {
    @ApiModelProperty(value = "账号")
    private String username;
    @ApiModelProperty(value = "用户密码")
    private String password;
    @ApiModelProperty(value = "登录类型(1:pc;2:App)")
    @NotBlank(message = "登录类型不能为空")
    private String type;
}

企业级规范化开发:LoginRespVO

package com.wz.lesson.vo.response;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
 * 后端返回值方向的VO
 */
@Data
public class LoginRespVO {
    @ApiModelProperty(value = "token")
    private String accessToken;
    @ApiModelProperty(value = "刷新token")
    private String refreshToken;

    // 以下属于用户个人信息,在后端数据返回时一同给前端,避免前端需要用户信息时还要去用户数据接口中取
    @ApiModelProperty(value = "用户名")
    private String username;
    @ApiModelProperty(value = "用户id")
    private String id;
    @ApiModelProperty(value = "电话")
    private String phone;
}

MyBatis使用pagehelper插件实现分页查询

使用格式:

//Mapper接口方式的调用,推荐这种使用方式。
 PageHelper.startPage(vo.getPageNum(),vo.getPageSize());
        //设置分页方法后第一条mybatis查询语句必须紧跟在startPage方法后
        List<SysRole> sysRoles =sysRoleMapper.selectAll(vo);
        PageInfo pageInfo=new PageInfo(list);

创建UserPageRespVO用于传递给前端数据

package com.wz.lesson.vo.response;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.util.List;

/**
 * 用于封装分页查询时候的条件的VO
 */
@Data
public class UserPageRespVO<T> {
    /**
     * 总记录数
     */
    @ApiModelProperty(value = "总记录数")
    private Long totalRows;
    /**
     * 总页数
     */
    @ApiModelProperty(value = "总页数")
    private Integer totalPages;
    /**
     * 当前第几页
     */
    @ApiModelProperty(value = "当前第几页")
    private Integer pageNum;
    /**
     * 每页记录数
     */
    @ApiModelProperty(value = "每页记录数")
    private Integer pageSize;
    /**
     * 当前页记录数
     */
    @ApiModelProperty(value = "当前页记录数")
    private Integer curPageSize;
    /**
     * 数据列表
     */
    @ApiModelProperty(value = "数据列表")
    private List<T> list;
}

创建UserPageReqVO用于接收前端参数

package com.wz.lesson.vo.request;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
 * 用于封装分页查询时候的条件的VO
 */
@Data
public class UserPageReqVO {

    @ApiModelProperty(value = "页数 默认值为1")
    private Integer pageNum = 1;

    @ApiModelProperty(value = "数据条数 默认值为10")
    private Integer pageSize = 10;
}

分页查询的动态SQL语句写入UserMapper.xml

deleted=1的意思是未被逻辑删除的用户

  <!--分页查询-->
  <!--注意此处语句末尾不能加;号,否则分页插件失效,原因是pagehelper插件实现分页的方式是SQL注入-->
  <select id="selectAll" resultMap="BaseResultMap">
    select
    <include refid="Base_Column_List" />
    from sys_user
    where deleted=1
  </select>

此处的selectAll方法我们在下面实现UserService层和UserServiceImpl类时一起实现,此处只是暂时写上,并没有具体方法。

自定义AccessControlFilter token认证

业务逻辑 会根据后面的shiro核心配置设置的策略对用户访问的接口进行过滤拦截、对业务访问token进行校验,
1.通过过滤器拦截用户请求接口、判断header是否携带有token、没有携带的话返回提示直接写入response 响应前端。
2.携带了token的话我们自定义shiro 用户认证的 UsernamePasswordToken 把前端携带过来的业务访问token(accessToken) 整合成
一个 UsernamePasswordToken
3.主体提交认证(getSubject(servletRequest,servletResponse).login(customUsernamePasswordToken)😉
4.要对用户认证抛出的异常进行try catch 封装处理用户认证抛出的异常,写入response响应应前端

自定义UsernamePasswordToken
package com.wz.lesson.shiro;

import org.apache.shiro.authc.UsernamePasswordToken;

/**
 * 因为我们使用JWT来生成Token
 * 所以需要自定义UsernamePasswordToken,使其不再需要Shiro提供的Token生成方式
 * 此方法用于代替Shiro默认的UsernamePasswordToken方法,我们只是重写了UsernamePasswordToken类中的getPrincipal和getCredentials两个方法,其他方法还是使用的UsernamePasswordToken原本的内容
 */
public class CustomUsernamePasswordToken extends UsernamePasswordToken {

    // 使用构造函数传参,重写2个Toke的返回方法,实现替换shiro默认token为jwtToken
    private String jwtToken;

    public CustomUsernamePasswordToken(String jwtToken) {
        this.jwtToken = jwtToken;
    }

    @Override
    public Object getPrincipal() {
        return jwtToken;
    }

    @Override
    public Object getCredentials() {
        return jwtToken;
    }
}
自定义 token 过滤器AccessControlFilter创建其子类CustomAccessControllerFilter

这个类主要是拦截需求认证的请求,首先验证客户端 header 是否携带了 token ,如果没有携带直接响应客户端,引导客户端到登录 界面进行登录操作,如果客户端 header 已经携带有 token 放开进入 shiro SecurityManager 验证

package com.wz.lesson.shiro;

import com.alibaba.fastjson.JSON;
import com.github.pagehelper.util.StringUtil;
import com.wz.lesson.constant.Constant;
import com.wz.lesson.exception.BusinessException;
import com.wz.lesson.exception.code.BaseResponseCode;
import com.wz.lesson.utils.DataResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.IOException;
import java.io.OutputStream;

/**
 *  自定义token过滤器AccessControlFilter
 *  作用是对Token进行拦截、校验、认证。
 */
@Slf4j
public class CustomAccessControllerFilter extends AccessControlFilter {

    /**
     * 初次处理Token
     * 如果返回true,则会跳转到下一个链式调用(过滤器、拦截器、 控制层等)
     * 如果返回false,则会跳转到onAccessDenied方法进行处理
     * @param servletRequest
     * @param servletResponse
     * @param o
     * @return
     * @throws Exception
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
        return false;
    }

    /**
     * 第二次处理token
     * isAccessAllowed的结果经过处理如果返回true,则会跳转到下一个链式调用(过滤器、拦截器、 控制层等)
     * 否则直接直接将处理失败结果的反馈信息返回到前端
     * @param servletRequest
     * @param servletResponse
     * @return
     * @throws Exception
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        log.info("request接口地址:{}",request.getRequestURI());
        log.info("request接口请求方式:{}",request.getMethod());
        // 取得Header中携带的Token
        String accessToken = request.getHeader(Constant.ACCESS_TOKEN);
        // 因为此类不在Spring容器中,所以全局统一异常管理对此类无效
        // try/catch的作用是监控异常并拿到异常反馈信息msg和状态码code
        try {
            //判断客户端是否携带accessToken
            if(StringUtils.isEmpty(accessToken)){
                throw new BusinessException(BaseResponseCode.TOKEN_NOT_NULL_ERROR);
            }
            // 将accessToken传入包装成UsernamePasswordToken
            CustomUsernamePasswordToken customUsernamePasswordToken = new CustomUsernamePasswordToken(accessToken);
            // 将customUsernamePasswordToken传入提交认证
            getSubject(servletRequest,servletResponse).login(customUsernamePasswordToken);
        } catch (BusinessException e){
            customResponse(servletResponse,e.getCode(),e.getMsg());
            e.printStackTrace();
            return false;
        } catch (AuthenticationException e){
            // 如果AuthenticationException异常属于自定义BusinessException主动抛出异常中的某种
            if(e.getCause() instanceof BusinessException){
                // 强转后反馈给前端
                BusinessException businessException = (BusinessException) e.getCause();
                customResponse(servletResponse,businessException.getCode(),businessException.getMsg());
            }else {
                // 使用专属的枚举类中专属异常
                customResponse(servletResponse,BaseResponseCode.TOKEN_LOSE_ERROR.getCode(),BaseResponseCode.TOKEN_LOSE_ERROR.getMsg());
            }
            return false;
        } catch (Exception e){
            customResponse(servletResponse,BaseResponseCode.SYSTEM_ERROR.getCode(),BaseResponseCode.SYSTEM_ERROR.getMsg());
            return false;
        }
        return true;
    }

    /**
     * 自定义响应前端方法
     * 此方法的作用是将异常的状态码Code、异常信息Msg将其写入response中,经过onAccessDenied方法调用本方法,实现在前端请求时反馈相关异常信息
     * @param response
     * @param code
     * @param msg
     */
    private void customResponse(ServletResponse response,int code,String msg){
        try {
            DataResult dataResult = DataResult.getResult(code,msg);
            // 设置字符串格式
            response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            response.setCharacterEncoding("UTF-8");
            String str = JSON.toJSONString(dataResult);
            OutputStream outputStream = response.getOutputStream();
            // 以UTF-8格式传入的比特字符到response信息中
            outputStream.write(str.getBytes("UTF-8"));
            outputStream.flush();
        } catch (IOException e) {
            log.error("customResponse....ERROR:{}",e);
        }
    }
}

自定义 Realm

主要是继承 AuthorizingRealm 实现两个比较关键的方法
doGetAuthorizationInfo(主要用于用户授权,就是设置用户所拥有的角色/权限)
doGetAuthenticationInfo(主要用户用户的认证,以前是验证用户名密码这里我们会改造成验证 token 一般来说客户端只需登录一
次后续的访问用 token来维护登录的状态,所以我们这里改造成严正 token)
用户认正业务逻辑 主体提交用户认证后、会流转到用户认证器(ModularRealmAuthenticator),用户认证器呢会通过自定义域中的
doGetAuthenticationInfo 获取用户的认证凭证(这里是token因为,我们判断用户是否是当前登录用户只需判断前端访问我们系统时候
所携带过来的token即可,所以呢我们这里返回给认证器的用户凭证credentials就是我们前端请求接口携带的业务token)
用户授权的业务逻辑 即主体提交授权后(subject.checkRoles(“xxx角色”)、subject.checkPermissions(“user:deleted”,“role:list”)、
shiro:hasPermission=“xxx”、@RequiresPermissions(“sys:permission:add”)),授权器(ModularRealmAuthorizer)就会通过自定义
域的doGetAuthorizationInfo方法获取该用户拥有的角色列表、权限列表、拿到用户拥有的权限/角色信息后就跟主体提交过来的授权
标识进行一一比较匹配如果匹配成功就说明该用户拥有访问这个资源的权限

package com.wz.lesson.shiro;

import com.wz.lesson.constant.Constant;
import com.wz.lesson.utils.JwtTokenUtil;
import io.jsonwebtoken.Claims;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

import java.util.Collection;

/**
 * 自定义Realm
 * 在学习Shiro中我们发现仅仅将数据源信息定义在ini文件中与我们实际开发环境有很大不兼容,所以我们希望能够自定义Realm。
 */
public class CustomRealm extends AuthorizingRealm {
    /**
     * 授权
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 取得accessToken
        String accessToken = (String) principals.getPrimaryPrincipal();
        // 解析accessToken,取得accessToken中携带的内容
        Claims claimsFromToken = JwtTokenUtil.getClaimsFromToken(accessToken);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        // 判断accessToken中权限不为空
        if(claimsFromToken.get(Constant.POWER_KEY) != null){
            // 添加用户权限
            info.addStringPermissions((Collection<String>) claimsFromToken.get(Constant.POWER_KEY));
        }
        // 判断accessToken中角色信息不为空
        if(claimsFromToken.get(Constant.ROLES_INFOS_KEY) != null){
            // 添加用户角色
            info.addRoles((Collection<String>) claimsFromToken.get(Constant.ROLES_INFOS_KEY));
        }
        // 返回授权完毕的结果
        return info;
    }

    /**
     * 认证
     * 密码验证
     * @param token
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //获取token
        CustomUsernamePasswordToken customUsernamePasswordToken = (CustomUsernamePasswordToken) token;
        // 传入Token
        // 方法:SimpleAuthenticationInfo(Object principal, Object credentials, String realmName)
        // 参数:第一个是当前用户认证信息,第二个是封装密码的对象,第三个是认证名
        // 并进行密码认证操作:Shiro自动操作 // this.getName()指当前类名,可以用""代替
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(customUsernamePasswordToken.getPrincipal(),customUsernamePasswordToken.getCredentials(),this.getName());
        // 返回认证结果
        return info;
    }

    /**
     * 设置支持令牌校验
     * 此方法作用是
     * 在用户认证器中,此方法的返回结果必须是true,不然会直接在认证中抛出异常认证失败
     * 所以此时我们将其重写,改为只要是我们自定义的Token就返回true
     * 原因是:每一个Realm都有一个supports方法,用于检测是否支持此Token, 而我在该函数中默认的采用了return false;
     * 所以必须要将其重写改为true
     * @param token
     * @return
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        // 只要token是我们自定义的Token就返回true
        return token instanceof CustomUsernamePasswordToken;
    }
}

自定义用户认证匹配方法

认证步骤分析
主要业务逻辑:
第一步:判断用户是否被锁定。(后面有用户锁定的功能,当用户锁定后我们会把该用户的id 用redis给标记起来、这里就是为了防止已
锁定的用户继续访问我们需要用户认证的资源)
否:下一步。
是:引导到登录界面。
第二步:判断用户是否被删除(后面我们有用户的删除功能,当用户被删除后我们同样用redis把该用户的id给标记起来、这里就是为了
防止已经删除的用户继续访问我们需要用户认证的资源)。
否:下一步。
是:引导到登录界面。
第三步:校验access_token 是否通过校验(这里把token是否正确和token是否已经过期合并在一起、只要满足其中一个条件即token不
正确获取token过期我们都会引导前端调用我们的token刷新接口进行重新获取token、token刷新的功能我们会在后面实现)
否:引导用户刷新token 拿到最新的token再重新请求当前的接口。
是:下一步。

自定义 HashedCredentialsMatcher

package com.wz.lesson.shiro;

import com.wz.lesson.constant.Constant;
import com.wz.lesson.exception.BusinessException;
import com.wz.lesson.exception.code.BaseResponseCode;
import com.wz.lesson.service.RedisService;
import com.wz.lesson.utils.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * 认证器核心类重写,用于定制化Token判断
 * 我们自己实现 CredentialsMatcher 的一个类来实现定制化的账户密码验证机制
 * 自定义用户认证匹配方法的类
 * 因为我们使用了自定义Token进行用户登录验证,所以源码中的用户认证匹配方法不再适用,所以我们得自定义认证器的核心匹配类(HashedCredentialsMatcher)重写它的匹配方法。
 */
@Slf4j
public class CustomHashedCredentialsMatcher extends HashedCredentialsMatcher {
    // 注入Redis工具类
    // 使用Redis存储Token
    @Autowired
    private RedisService redisService;

    /**
     * 主要业务逻辑:
     * 第一步:判断用户是否被锁定。(后面有用户锁定的功能,当用户锁定后我们会把该用户的id 用redis给标记起来、这里就是为了防止已
     *  锁定的用户继续访问我们需要用户认证的资源)
     *  否:下一步。
     *  是:引导到登录界面。
     * 第二步:判断用户是否被删除(后面我们有用户的删除功能,当用户被删除后我们同样用redis把该用户的id给标记起来、这里就是为了
     *  防止已经删除的用户继续访问我们需要用户认证的资源)。
     *  否:下一步。
     *  是:引导到登录界面。
     * 第三步:校验access_token 是否通过校验(这里把token是否正确和token是否已经过期合并在一起、只要满足其中一个条件即token不
     *  正确获取token过期我们都会引导前端调用我们的token刷新接口进行重新获取token、token刷新的功能我们会在后面实现)
     *  否:引导用户刷新token 拿到最新的token再重新请求当前的接口。
     *  是:下一步。
     */
    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        // 这里token和info两个参数均是根据我们自定义的CustomUsernamePasswordToken中的JwtToken来的
        // 所以他们任意一个都可以通过调用方法取得JwtToken,可以说token和info两个参数都是不同数据类型,但可以理解为它们就是我们自定义的CustomUsernamePasswordToken类型
        // 故而此时我们直接将其强转成CustomUsernamePasswordToken格式
        CustomUsernamePasswordToken customUsernamePasswordToken= (CustomUsernamePasswordToken) token;
        // 此时将强转token参数取得的自定义CustomUsernamePasswordToken类型的customUsernamePasswordToken中的JwtToken取出,即为accessToken
        String accessToken = (String) customUsernamePasswordToken.getCredentials();
        // 取得用户Id
        String userId = JwtTokenUtil.getUserId(accessToken);
        log.info("doCredentialsMatch...userId:{}",userId);
        // 使用指定常量DELETED_USER_KEY,在Redis中判断用户是否被删除
        if(redisService.hasKey(Constant.DELETED_USER_KEY+userId)){
            throw new BusinessException(BaseResponseCode.ACCOUNT_HAS_DELETED_ERROR);
        }
        // 使用指定常量ACCOUNT_LOCK_KEY,判断用户是否被锁定
        // 此判断条件一旦成立,则跳转到登录页面
        if(redisService.hasKey(Constant.ACCOUNT_LOCK_KEY+userId)){
            throw new BusinessException(BaseResponseCode.ACCOUNT_LOCK);
        }
        // 判断校验Token是否正确,如果Token正确JwtTokenUtil.validateToken(accessToken)方法返回true,此时使用!取反,实现不执行if中的内容
        if(!JwtTokenUtil.validateToken(accessToken)){
            throw new BusinessException(BaseResponseCode.TOKEN_PAST_DUE);
        }
        return true;
    }
}

Shiro 核心策略配置

shiro 的配置主要有 Realm、securityManager、shiroFilterFactoryBean 三个关键的配置

package com.wz.lesson.config;

import com.wz.lesson.shiro.CustomAccessControllerFilter;
import com.wz.lesson.shiro.CustomHashedCredentialsMatcher;
import com.wz.lesson.shiro.CustomRealm;
import com.wz.lesson.shiro.RedisCacheManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.List;

/**
 * Shiro策略配置
 */
@Configuration
public class ShiroConfig {


    // 第一步
    /**
     * 自定义密码校验
     * 自定义认证器核心类注入到Bean
     *
     * @return
     */
    @Bean
    public CustomHashedCredentialsMatcher customHashedCredentialsMatcher(){
        return new CustomHashedCredentialsMatcher();
    }

    
	// 第二步
    /**
     * 注入自定义域Realm
     */
    @Bean
    public CustomRealm customRealm(){
        CustomRealm customRealm=new CustomRealm();
        customRealm.setCredentialsMatcher(customHashedCredentialsMatcher());
        // 配置换粗管理器到自定义域中
        customRealm.setCacheManager(redisCacheManager());
        return customRealm;
    }


    /**
     * 安全管理器环境
     */
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        // 注入自定义Realm域到安全管理器中
        defaultWebSecurityManager.setRealm(customRealm());
        return defaultWebSecurityManager;
    }

    /**
     * 核心策略:过滤器:这里主要是配置一些要放行的url 和 要拦截认证的url
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        // Shiro工厂生成过滤器配置的对象
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 安全管理器环境配置传入过滤器
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // ------------
        // 自定义拦截器限制并发人数
        // 存储过滤器配置的容器
        LinkedHashMap<String, Filter> linkedHashMap = new LinkedHashMap<>();
        // 存入参数token
        linkedHashMap.put("token", new CustomAccessControllerFilter());
        // 将配置传入Shiro工厂生成的过滤器配置的对象中
        shiroFilterFactoryBean.setFilters(linkedHashMap);
        // ------------
        // 存储拦截策略配置的容器
        LinkedHashMap<String, String> linked = new LinkedHashMap<>();
        // 存入不需要拦截(anon)的接口:登录接口、刷新token接口
        linked.put("/user/login", "anon");
        linked.put("/user/refresh", "anon");
        // 特殊操作 // 跨域请求
        linked.put("/", "anon");
        linked.put("/csrf", "anon");
        //存入不需要拦截(anon)的接口:swagger-ui地址
        linked.put("/swagger/**", "anon");
        linked.put("/v2/api-docs", "anon");
        linked.put("/swagger-ui.html", "anon");
        linked.put("/swagger-ui.html#", "anon");
        linked.put("/swagger-resources/**", "anon");
        linked.put("/webjars/**", "anon");
        linked.put("/favicon.ico", "anon");
        linked.put("/captcha.jpg", "anon");
        //存入不需要拦截(anon)的接口:druid sql监控配置
        linked.put("/druid/**", "anon");
        // 配置需要拦截后认证成功才放行的请求:所有请求
        // token:这些请求必须经过token过滤器验证
        // authc:这些请求必须经过我们配置的shiro授权验证
        linked.put("/**", "token,authc");
        // 将配置传入Shiro工厂生成的过滤器配置的对象中
        shiroFilterFactoryBean.setFilterChainDefinitionMap(linked);

        // 将配置好的拦截策略返回,使其被shiro源码读取实现拦截策略的设置
        return shiroFilterFactoryBean;
    }

    /**
     * 开启shiro aop注解支持.
     * 使用代理方式;所以需要开启代码支持;
     * org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor
     * 开启shiro注解的原因是我们将在controller层中对需要判断用户权限的方法使用注解@RequiresPermissions进行判权处理,判断用户是只读还是读写等,其中用户权限信息从数据库中拿取
     *
     * @throws
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
}

授权验证

用户列表接口加速授权标识

    @PostMapping("/page")
    @ApiOperation(value = "分页查询用户接口")
    @RequiresPermissions("sys:user:list") // Shiro AOP生效 // 验证用户是否登录 // 权限是list
    public String page(@RequestBody UserPageReqVO vo){
        DataResult dataResult = DataResult.success();
        UserPageRespVO<SysUser> sysUserUserPageRespVO = userService.pageInfo(vo);
        dataResult.setData(sysUserUserPageRespVO);
        String str = JSON.toJSONString(dataResult);
        return str;
    }

加上无权限情况的异常监控

无权限异常提示枚举

NOT_PERMISSION(4030001,"没有权限访问该资源"),

在全局异常监听RestExceptionHandler类中加入此异常

 @ExceptionHandler(UnauthorizedException.class)
    public DataResult unauthorizedException(UnauthorizedException e){
        log.error("UnauthorizedException,{},{}",e.getLocalizedMessage(),e);
        return DataResult.getResult(BaseResponseCode.NOT_PERMISSION);
   }

Redis 缓存授权信息

自定义一个缓存工具类

RedisCache<K, V> 实现 shiro Cache<K, V> 缓存接口,并重写 Cache<K, V> get、put、remove、clear、size、keys、values等方法,这些方法都是 shiro 在对缓存的一些操作,就是当 shiro 操作缓存的时候都会调用相应的方法,我们只需重写这些相应的方法就可以把 shiro 的缓存信息存入到 redis 了。这就是一个优秀的开源框架所具备的扩展性,它提供了一个cacheManager 缓存管理器 我们只需重写这个管理器即可

RedisCache

package com.wz.lesson.shiro;

import com.alibaba.fastjson.JSON;
import com.wz.lesson.constant.Constant;
import com.wz.lesson.service.RedisService;
import com.wz.lesson.utils.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.commons.collections.CollectionUtils;

import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 自定义缓存工具类
 * 重写Shiro默认的缓存操作
 * RedisCache<K, V> 实现 shiro Cache<K, V> 缓存接口,并重写 Cache<K, V> get、put、remove、clear、size、keys、values等方
 * 法,这些方法都是 shiro 在对缓存的一些操作,就是当 shiro 操作缓存的时候都会调用相应的方法,我们只需重写这些相应的方法就可
 * 以把 shiro 的缓存信息存入到 redis 了。这就是一个优秀的开源框架所具备的扩展性,它提供了一个cacheManager 缓存管理器 我们
 * 只需重新这个管理器即可。
 */
@Slf4j
public class RedisCache<K,V> implements Cache<K,V> {

    private RedisService redisService;
    // 缓存key
    private String cacheKey = Constant.IDENTIFY_CACHE_KEY;
    // 设置过期时间为24小时
    private long expire = 24;
    // 有参构造函数
    // 在RedisCacheManager类中通过实例化本类时在构造函数中传入redisService
    public RedisCache(RedisService redisService) {
        this.redisService = redisService;
    }

    /**
     * 在redis中通过key取得value
     * @param k
     * @return
     * @throws CacheException
     */
    @Override
    public V get(K k) throws CacheException {
        log.info("Shiro从缓存中获取数据KEY值[{}]",k);
        // key为空则返回空
        if (k == null) {
            return null;
        }
        try {
            // 从下面的Token格式化方法中取得格式化后的Token(key)
            String redisCacheKey = getCacheKey(k);
            // 使用格式化后的token(key)来取得redis中对应的value
            Object rawValue = redisService.get(redisCacheKey);
            // redis不存在对应的数据,则返回空
            if (rawValue == null) {
                return null;
            }
            // 将取得的数据 和 SimpleAuthorizationInfo结合后,转为SimpleAuthorizationInfo
            SimpleAuthorizationInfo simpleAuthenticationInfo = JSON.parseObject(rawValue.toString(),SimpleAuthorizationInfo.class);
            // 将取得的simpleAuthenticationInfo强转为Value的类型
            V value = (V) simpleAuthenticationInfo;
            // 返回Value
            return value;
        } catch (Exception e) {
            throw new CacheException(e);
        }
    }


    /**
     * 将key-value存入redis
     * @param k
     * @param v
     * @return
     * @throws CacheException
     */
    @Override
    public V put(K k, V v) throws CacheException {
        log.info("put k [{}]",k);
        if (k == null) {
            log.warn("Saving a null k is meaningless, return v directly without call Redis.");
            return v;
        }
        try {
            // 格式化Token(key)
            String redisCacheKey = getCacheKey(k);
            // 将格式化完成后的key、过期时间、过期时间单位传入redis中
            redisService.set(redisCacheKey, v != null ? v : null, expire, TimeUnit.HOURS);
            // 返回value
            return v;
        } catch (Exception e) {
            throw new CacheException(e);
        }
    }

    // 根据key移除value
    @Override
    public V remove(K key) throws CacheException {
        log.info("remove key [{}]",key);
        if (key == null) {
            return null;
        }
        try {
            String redisCacheKey = getCacheKey(key);
            Object rawValue = redisService.get(redisCacheKey);
            V previous = (V) rawValue;
            redisService.delete(redisCacheKey);
            return previous;
        } catch (Exception e) {
            throw new CacheException(e);
        }
    }

    // 清空redis
    @Override
    public void clear() throws CacheException {
        log.debug("clear cache");
        Set<String> keys = null;
        try {
            keys = redisService.keys(this.cacheKey + "*");
        } catch (Exception e) {
            log.error("get keys error", e);
        }
        if (keys == null || keys.size() == 0) {
            return;
        }
        for (String key: keys) {
            redisService.delete(key);
        }
    }

    // 取得当前redis中存储的数目
    @Override
    public int size() {
        int result = 0;
        try {
            result = redisService.keys(this.cacheKey + "*").size();
        } catch (Exception e) {
            log.error("get keys error", e);
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    @Override
    public Set<K> keys() {
        Set<String> keys = null;
        try {
            keys = redisService.keys(this.cacheKey + "*");
        } catch (Exception e) {
            log.error("get keys error", e);
            return Collections.emptySet();
        }
        if (CollectionUtils.isEmpty(keys)) {
            return Collections.emptySet();
        }
        Set<K> convertedKeys = new HashSet<>();
        for (String key:keys) {
            try {
                convertedKeys.add((K) key);
            } catch (Exception e) {
                log.error("deserialize keys error", e);
            }
        }
        return convertedKeys;
    }

    @Override
    public Collection<V> values() {
        Set<String> keys = null;
        try {
            keys = redisService.keys(this.cacheKey + "*");
        } catch (Exception e) {
            log.error("get values error", e);
            return Collections.emptySet();
        }
        if (CollectionUtils.isEmpty(keys)) {
            return Collections.emptySet();
        }
        List<V> values = new ArrayList<>(keys.size());
        for (String key : keys) {
            V value = null;
            try {
                value = (V) redisService.get(key);
            } catch (Exception e) {
                log.error("deserialize values= error", e);
            }
            if (value != null) {
                values.add(value);
            }
        }
        return Collections.unmodifiableList(values);
    }


    /**
     * 格式化Token为以下格式
     *      静态常量 + userId
     */
    private String getCacheKey(K token){
        // 如果有token,即没有key,直接返回null
        if(token == null){
            return null;
        } else {
            String userId = JwtTokenUtil.getUserId(token.toString());
            return this.cacheKey +userId;
        }
    }
}

创建一个缓存管理器

RedisCacheManager 实现 CacheManager接口的 getCache 方法

RedisCacheManager

package com.wz.lesson.shiro;

import com.wz.lesson.constant.Constant;
import com.wz.lesson.service.RedisService;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.cache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * 缓存管理器
 */
public class RedisCacheManager implements CacheManager {
    @Autowired
    private RedisService redisService;

    // 将redisService通过构造函数的方式传入RedisCache中
    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new RedisCache<>(redisService);
    }
}

shiro 配置 redis 缓存

修改 ShiroConfig 配置类,加入如下代码到ShiroConfig中

    /**
     * 第三步
     *  shiro 配置 redis 缓存
     * 将自定义的缓存管理器注入到bean中
     * @return
     */
    @Bean
    public RedisCacheManager redisCacheManager(){
        return new RedisCacheManager();
    }

ShiroConfig.java变为如下(最终形态)

package com.wz.lesson.config;

import com.wz.lesson.shiro.CustomAccessControllerFilter;
import com.wz.lesson.shiro.CustomHashedCredentialsMatcher;
import com.wz.lesson.shiro.CustomRealm;
import com.wz.lesson.shiro.RedisCacheManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.List;

/**
 * Shiro策略配置
 */
@Configuration
public class ShiroConfig {

    //
    // 此步骤在配置redis来管理Shiro缓存时使用


    /**
     * 自定义密码校验
     * 自定义认证器核心类注入到Bean
     *
     * @return
     */
    @Bean
    public CustomHashedCredentialsMatcher customHashedCredentialsMatcher(){
        return new CustomHashedCredentialsMatcher();
    }


    /**
     * 第一步
     * 注入自定义域Realm
     */
    @Bean
    public CustomRealm customRealm(){
        CustomRealm customRealm=new CustomRealm();
        customRealm.setCredentialsMatcher(customHashedCredentialsMatcher());
        // 配置换粗管理器到自定义域中
        customRealm.setCacheManager(redisCacheManager());
        return customRealm;
    }


    /**
     * 第二步
     * 安全管理器环境
     */
    @Bean
    public SecurityManager securityManager() {
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        // 注入自定义Realm域到安全管理器中
        defaultWebSecurityManager.setRealm(customRealm());
        return defaultWebSecurityManager;
    }

    /**
     * 第三步
     *  shiro 配置 redis 缓存
     * 将自定义的缓存管理器注入到bean中
     * @return
     */
    @Bean
    public RedisCacheManager redisCacheManager(){
        return new RedisCacheManager();
    }



    /**
     * 核心策略:过滤器
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        // Shiro工厂生成过滤器配置的对象
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 安全管理器环境配置传入过滤器
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // ------------
        // 自定义拦截器限制并发人数
        // 存储过滤器配置的容器
        LinkedHashMap<String, Filter> linkedHashMap = new LinkedHashMap<>();
        // 存入参数token
        linkedHashMap.put("token", new CustomAccessControllerFilter());
        // 将配置传入Shiro工厂生成的过滤器配置的对象中
        shiroFilterFactoryBean.setFilters(linkedHashMap);
        // ------------
        // 存储拦截策略配置的容器
        LinkedHashMap<String, String> linked = new LinkedHashMap<>();
        // 存入不需要拦截(anon)的接口:登录接口、刷新token接口
        linked.put("/user/login", "anon");
        linked.put("/user/refresh", "anon");
        // 特殊操作 // 跨域请求
        linked.put("/", "anon");
        linked.put("/csrf", "anon");
        //存入不需要拦截(anon)的接口:swagger-ui地址
        linked.put("/swagger/**", "anon");
        linked.put("/v2/api-docs", "anon");
        linked.put("/swagger-ui.html", "anon");
        linked.put("/swagger-ui.html#", "anon");
        linked.put("/swagger-resources/**", "anon");
        linked.put("/webjars/**", "anon");
        linked.put("/favicon.ico", "anon");
        linked.put("/captcha.jpg", "anon");
        //存入不需要拦截(anon)的接口:druid sql监控配置
        linked.put("/druid/**", "anon");
        // 配置需要拦截后认证成功才放行的请求:所有请求
        // token:这些请求必须经过token过滤器验证
        // authc:这些请求必须经过我们配置的shiro授权验证
        linked.put("/**", "token,authc");
        // 将配置传入Shiro工厂生成的过滤器配置的对象中
        shiroFilterFactoryBean.setFilterChainDefinitionMap(linked);

        // 将配置好的拦截策略返回,使其被shiro源码读取实现拦截策略的设置
        return shiroFilterFactoryBean;
    }

    /**
     * 开启shiro aop注解支持.
     * 使用代理方式;所以需要开启代码支持;
     * org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor
     * 开启shiro注解的原因是我们将在controller层中对需要判断用户权限的方法使用注解@RequiresPermissions进行判权处理,判断用户是只读还是读写等,其中用户权限信息从数据库中拿取
     * @throws
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
}

创建 UserService 接口

package com.wz.lesson.service;

import com.github.pagehelper.PageInfo;
import com.wz.lesson.entity.SysUser;
import com.wz.lesson.vo.request.LoginReqVO;
import com.wz.lesson.vo.request.TokenAgainVO;
import com.wz.lesson.vo.request.UserPageReqVO;
import com.wz.lesson.vo.response.LoginRespVO;
import com.wz.lesson.vo.response.UserPageRespVO;

public interface UserService {
    // 登录
    LoginRespVO login(LoginReqVO vo);
    // 双Token刷新
    LoginRespVO againToken(TokenAgainVO vo);
    // 分页查询
    UserPageRespVO<SysUser> pageInfo(UserPageReqVO vo);
}

创建UserService实现类UserServiceImpl

在此接口中实现双Token刷新

package com.wz.lesson.service.Impl;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.wz.lesson.constant.Constant;
import com.wz.lesson.entity.SysUser;
import com.wz.lesson.exception.BusinessException;
import com.wz.lesson.exception.code.BaseResponseCode;
import com.wz.lesson.mapper.SysUserMapper;
import com.wz.lesson.service.RedisService;
import com.wz.lesson.service.UserService;
import com.wz.lesson.utils.JwtTokenUtil;
import com.wz.lesson.utils.PageUtil;
import com.wz.lesson.utils.PasswordUtils;
import com.wz.lesson.vo.request.LoginReqVO;
import com.wz.lesson.vo.request.TokenAgainVO;
import com.wz.lesson.vo.request.UserPageReqVO;
import com.wz.lesson.vo.response.LoginRespVO;
import com.wz.lesson.vo.response.UserPageRespVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
@Slf4j
public class UserServiceImpl implements UserService {
    @Autowired
    private SysUserMapper sysUserMapper;

    @Autowired
    private RedisService redisService;

    /**
     * 双Token刷新
     * 使用refreshToken刷新accessToken
     * refreshToken自动刷新
     * 业务逻辑如下:
     *  accessToken过期后,用户仍然在最大登录保持间隔时间内,此时使用refreshToken来刷新accessToken,
     *  但是实际上并不是用refreshToken来刷新accessToken,refreshToken只是起到了一个判断用户距上次登录时间是否超过了最大登录保持间隔时间,即refreshToken的过期时间
     *  如果是,则重新申请accessToken,并写入到redis中覆盖掉原本的过期的accessToken,并将心accessToken返回给前端。
     *  如果refreshToken没有过期但是距离过期时间不足2小时,那么就重新刷新refreshToken,并写入到redis中覆盖掉原本的即将过期的refreshToken,使其又能保持长时间登录状态,使频繁访问自己账号的用户无需登录(一切由前端通过refreshToken刷新accessToken搞定),提高用户体验。
     *  前端传参:
     *  前端需要传入TokenAgainVO类中所有元素,其中TokenAgainVO下的refreshToken来自于登录时的反馈
     * @param vo
     * @return
     */
    @Override
    public LoginRespVO againToken(TokenAgainVO vo) {
        SysUser sysUser = sysUserMapper.selectByUserName(vo.getUsername());
        // 判断用户是否存在
        if(sysUser == null){
            throw new BusinessException(BaseResponseCode.USER_ERROR);
        }
        // 验证用户账号是否被锁定
        if(sysUser.getStatus()==2){
            throw new BusinessException(BaseResponseCode.USER_LOCK_ERROR);
        }
        // 如果必要参数为空
        // 这里我们暂时使用的LoginRespVO接收参数,因为LoginRespVO是返回值VO,故而在此我们不能再LoginRespVO中去使用校验框架,所以在此手动校验
        if((vo.getUsername().isEmpty()) && (vo.getRefreshToken().isEmpty())){
            throw new BusinessException(BaseResponseCode.DATA_ERROR);
        }
        // 取得refreshToken和当前传入的refreshToken匹配
        String str = (String)redisService.get("refreshToken:" + vo.getUsername());
        if(!str.equals(vo.getRefreshToken())){
            throw new BusinessException(BaseResponseCode.TOKEN_LOSE_ERROR);
        }
        // 验证refreshToken是否过期
        Boolean aBoolean = JwtTokenUtil.validateToken(vo.getRefreshToken());
        if(aBoolean == false){
            throw new BusinessException(BaseResponseCode.REFRESH_TOKEN_LOSE_ERROR);
        }
        // 如果refreshToken的过期时间小于2小时 且 未过期,那么就刷新refreshToken,使其重新开始计算有效时间
        String refreshToken = null;
        if(JwtTokenUtil.getRemainingTime(vo.getRefreshToken()) < (3600000*2) ){
            // Map封装刷新Token的负载
            Map<String,Object> refreshClaims = new HashMap<>();
            refreshClaims.put(Constant.JWT_USER_NAME,vo.getUsername());// 用户名
            // 判断刷新token是pc还是app类型,以实现不同时间的过期时间,type=1是pc,type=0是app
            if(vo.getType().equals("1")){
                // 传入负载生成PC端refreshToken
                refreshToken=JwtTokenUtil.getRefreshToken(vo.getId(),refreshClaims);
                // 将刷新后的refreshToken放入Redis,此句不能放在外面,不然会导致原本超过2小时过期时间的refreshToken为null
                redisService.set("refreshToken:"+vo.getUsername(),refreshToken);
            }else if(vo.getType().equals("0")){
                // 传入负载生成移动端refreshToken
                refreshToken=JwtTokenUtil.getRefreshAppToken(vo.getId(),refreshClaims);
                redisService.set("refreshToken:"+vo.getUsername(),refreshToken);
            }else{
                throw new BusinessException(BaseResponseCode.USER_TYPE_ERROR);
            }
        }
        // 生成新的accessToken需要封装的信息如下
        Map<String,Object> claims = new HashMap<>();
        claims.put(Constant.JWT_USER_NAME,vo.getUsername());// 用户名
        claims.put(Constant.ROLES_INFOS_KEY,getRolesByUserId(vo.getId())); // 角色信息
        claims.put(Constant.POWER_KEY,getPermissionsByUserId(vo.getId())); // 用户权限信息
        String accessToken = JwtTokenUtil.getAccessToken(vo.getId(),claims); // 取得accessToken
        System.out.println("老accessToken过期时间:"+JwtTokenUtil.getRemainingTime((String)redisService.get("accessToken:"+vo.getUsername())));
        // 将生成的accessToken存入redis中,用于覆盖redis中原来的accessToken
        redisService.set("accessToken:"+vo.getUsername(),accessToken);
        // 封装用户id、accessToken返回到前端。
        LoginRespVO loginRespVO = new LoginRespVO();
        loginRespVO.setId(vo.getId());
        loginRespVO.setAccessToken(accessToken);
        loginRespVO.setRefreshToken(refreshToken); // 如果refreshToken为空说明其有效时间大于2小时
        System.out.println("新accessToken过期时间:"+JwtTokenUtil.getRemainingTime(accessToken));
        return loginRespVO;
    }

    @Override
    public LoginRespVO login(LoginReqVO vo) {
        SysUser sysUser = sysUserMapper.selectByUserName(vo.getUsername());
        // 判断用户是否存在
        if(sysUser == null){
            throw new BusinessException(BaseResponseCode.USER_ERROR);
        }
        // 验证用户账号是否被锁定
        if(sysUser.getStatus()==2){
            throw new BusinessException(BaseResponseCode.USER_LOCK_ERROR);
        }
        // 使用工具类判断密码是否正确 // Salt:加密盐值
        if(!PasswordUtils.matches(sysUser.getSalt(),vo.getPassword(),sysUser.getPassword())){
            throw new BusinessException(BaseResponseCode.USER_PASSWORD_ERROR);
        }
        // 签发Token

        //--------------accessToken----------------
        // Map封装用户基础信息作为负载
        Map<String,Object> claims = new HashMap<>();
        claims.put(Constant.JWT_USER_NAME,sysUser.getUsername());// 用户名
        claims.put(Constant.ROLES_INFOS_KEY,getRolesByUserId(sysUser.getId())); // 角色信息
        claims.put(Constant.POWER_KEY,getPermissionsByUserId(sysUser.getId())); // 用户权限信息
        // 将封装的用户信息(负载)传入,生成accessToken
        String accessToken = JwtTokenUtil.getAccessToken(sysUser.getId(),claims);
        // 打印
        log.info("accessToken:{}",accessToken);
        //-------------refreshToken--------------
        // Map封装刷新Token的负载
        Map<String,Object> refreshClaims = new HashMap<>();
        refreshClaims.put(Constant.JWT_USER_NAME,sysUser.getUsername());// 用户名
        String refreshToken = null;
        // 判断刷新token是pc还是app类型,以实现不同时间的过期时间
        if(vo.getType().equals("1")){
            // 传入负载生成PC端refreshToken
            refreshToken=JwtTokenUtil.getRefreshToken(sysUser.getId(),refreshClaims);
        }else{
            // 传入负载生成移动端refreshToken
            refreshToken=JwtTokenUtil.getRefreshAppToken(sysUser.getId(),refreshClaims);
        }
        // 打印
        log.info("refreshToken:{}",refreshToken);
        // 将生成的accessToken和refreshToken存入redis中,并使其key为此用户用户名
        redisService.set("accessToken:"+vo.getUsername(),accessToken);
        redisService.set("refreshToken:"+vo.getUsername(),refreshToken);
        // 封装用户id、accessToken、refreshToken返回到前端。
        LoginRespVO loginRespVO = new LoginRespVO();
        loginRespVO.setId(sysUser.getId());
        loginRespVO.setAccessToken(accessToken);
        loginRespVO.setRefreshToken(refreshToken);
        return loginRespVO;
    }

    // -----------使用固定数据模仿数据库查询操作---------
    /**
     * 实现获取角色业务
     * 获取用户的角色
     * 这里先用mock数据代替
     * 后面我们讲到权限管理系统后 再从 DB 读取
     */
    private List<String> getRolesByUserId(String userId){
        List<String> list=new ArrayList<>();
        // 用户ID是否相等,如果相等,赋予管理员admin权限;如果不等,赋予测试员test权限
        if("9a26f5f1-cbd2-473d-82db-1d6dcf4598f8".equals(userId)){
            list.add("admin");
        }else{
            list.add("test");
        }
        return  list;
    }

    /**
     * 实现获取菜单权限业务
     * 获取用户的权限
     * 这里先用mock数据代替
     * 后面我们讲到权限管理系统后 再从 DB 读取
     */
    private List<String> getPermissionsByUserId(String userId) {
        List<String> list = new ArrayList<>();
        // 如果用户ID等于管理员ID,则允许增删改查操作,如果不等于管理员ID,则只能进行增操作
        if ("9a26f5f1-cbd2-473d-82db-1d6dcf4598f8".equals(userId)) {
            // 用户权限:增删改查
            list.add("sys:user:add");
            list.add("sys:user:list");
            list.add("sys:user:update");
            list.add("sys:user:delete");
        } else {
            list.add("sys:user:add");
        }
        return list;
    }

    /**
     * 实现分页查询方法
     * @param vo
     * @return
     */
    @Override
    public UserPageRespVO<SysUser> pageInfo(UserPageReqVO vo) {
        PageHelper.startPage(vo.getPageNum(),vo.getPageSize());
        List<SysUser> sysUsers = sysUserMapper.selectAll();
        return PageUtil.getPageVO(sysUsers);
    }
}

实现具体登录业务

业务逻辑如下

首先拿到用户名先去 DB 查找存不存在该用户?

存在?

存在:则验证密码是否正确?

  	**正确:生成业务 token 和刷新 token 响应客户端。**

不正确:抛出相应异常。

不存在:则抛出运行时异常,响应客户端。

SysUserMapper.java 创建方法:根据用户名查询用户
SysUser getUserInfoByName(String username);
SysUserMapper.xml创建相同的名称方法
  <!-- @Description:     根据用户名获取用户信息-->
  <select id="getUserInfoByName" resultMap="BaseResultMap">
   SELECT <include refid="Base_Column_List"></include>
   FROM sys_user
   WHERE username=#{username}
   AND deleted=1
  </select>

实现登录业务

新增密码校验工具类 PasswordEncoder、PasswordUtils

这两个工具类已经封装好,我们开箱即用。

PasswordEncoder

package com.wz.lesson.utils;

import java.security.MessageDigest;

/**
* @ClassName:       PasswordEncoder
*                   密码加密
*/
public class PasswordEncoder {

	private final static String[] hexDigits = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d",
			"e", "f" };

	private final static String MD5 = "MD5";
	private final static String SHA = "SHA";
	
	private Object salt;
	private String algorithm;

	public PasswordEncoder(Object salt) {
		this(salt, MD5);
	}
	
	public PasswordEncoder(Object salt, String algorithm) {
		this.salt = salt;
		this.algorithm = algorithm;
	}

	/**
	 * 密码加密
	 * @param rawPass
	 * @return
	 */
	public String encode(String rawPass) {
		String result = null;
		try {
			MessageDigest md = MessageDigest.getInstance(algorithm);
			// 加密后的字符串
			result = byteArrayToHexString(md.digest(mergePasswordAndSalt(rawPass).getBytes("utf-8")));
		} catch (Exception ex) {
		}
		return result;
	}

	/**
	 * 密码匹配验证
	 * @param encPass 密文
	 * @param rawPass 明文
	 * @return
	 */
	public boolean matches(String encPass, String rawPass) {
		String pass1 = "" + encPass;
		String pass2 = encode(rawPass);

		return pass1.equals(pass2);
	}

	private String mergePasswordAndSalt(String password) {
		if (password == null) {
			password = "";
		}

		if ((salt == null) || "".equals(salt)) {
			return password;
		} else {
			return password + "{" + salt.toString() + "}";
		}
	}

	/**
	 * 转换字节数组为16进制字串
	 * 
	 * @param b
	 *            字节数组
	 * @return 16进制字串
	 */
	private String byteArrayToHexString(byte[] b) {
		StringBuffer resultSb = new StringBuffer();
		for (int i = 0; i < b.length; i++) {
			resultSb.append(byteToHexString(b[i]));
		}
		return resultSb.toString();
	}

	/**
	 * 将字节转换为16进制
	 * @param b
	 * @return
	 */
	private static String byteToHexString(byte b) {
		int n = b;
		if (n < 0)
			n = 256 + n;
		int d1 = n / 16;
		int d2 = n % 16;
		return hexDigits[d1] + hexDigits[d2];
	}

	public static void main(String[] args) {
	}
}

PasswordUtils

package com.wz.lesson.utils;

import java.util.UUID;

/**
* @ClassName:       PasswordUtils
*                   密码工具类
*/
public class PasswordUtils {

	/**
	 * 匹配密码
	 * @param salt 盐
	 * @param rawPass 明文 
	 * @param encPass 密文
	 * @return
	 */
	public static boolean matches(String salt, String rawPass, String encPass) {
		return new PasswordEncoder(salt).matches(encPass, rawPass);
	}
	
	/**
	 * 明文密码加密
	 * @param rawPass 明文
	 * @param salt
	 * @return
	 */
	public static String encode(String rawPass, String salt) {
		return new PasswordEncoder(salt).encode(rawPass);
	}

	/**
	 * 获取加密盐
	 * @return
	 */
	public static String getSalt() {
		return UUID.randomUUID().toString().replaceAll("-", "").substring(0, 20);
	}
}

控制层UserController实现

package com.wz.lesson.controller;

import com.alibaba.fastjson.JSON;
import com.github.pagehelper.PageInfo;
import com.wz.lesson.entity.SysUser;
import com.wz.lesson.exception.BusinessException;
import com.wz.lesson.exception.code.BaseResponseCode;
import com.wz.lesson.exception.code.ResponseCodeInterface;
import com.wz.lesson.service.RedisService;
import com.wz.lesson.service.UserService;
import com.wz.lesson.utils.DataResult;
import com.wz.lesson.utils.JwtTokenUtil;
import com.wz.lesson.vo.request.LoginReqVO;
import com.wz.lesson.vo.request.TokenAgainVO;
import com.wz.lesson.vo.request.UserPageReqVO;
import com.wz.lesson.vo.response.LoginRespVO;
import com.wz.lesson.vo.response.UserPageRespVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@Api(tags = "用户模块相关接口")
@CrossOrigin
@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/user/login")
    @ApiOperation(value = "用户登录接口")
    public String login(@RequestBody @Valid LoginReqVO vo){
        DataResult dataResult = DataResult.success();
        dataResult.setData(userService.login(vo));
        String str = JSON.toJSONString(dataResult);
        return str;
    }

    @PostMapping("/page")
    @ApiOperation(value = "分页查询用户接口")
    @RequiresPermissions("sys:user:list") // Shiro AOP生效 // 验证用户是否登录 // 权限是list
    public String page(@RequestBody UserPageReqVO vo){
        DataResult dataResult = DataResult.success();
        UserPageRespVO<SysUser> sysUserUserPageRespVO = userService.pageInfo(vo);
        dataResult.setData(sysUserUserPageRespVO);
        String str = JSON.toJSONString(dataResult);
        return str;
    }

    /**
     * 用指定用户的 refreshToken 来刷新 accessToken
     * @param vo
     * @return
     */
    @PostMapping("/user/refresh")
    @ApiOperation(value = "刷新token接口")
    public String refresh(@RequestBody TokenAgainVO vo){
        DataResult dataResult = DataResult.success();
        LoginRespVO loginRespVO = userService.againToken(vo);
        dataResult.setData(loginRespVO);
        String str = JSON.toJSONString(dataResult);
        return str;
    }
}

测试:

启动项目

访问swagger

测试登录:取得token在这里插入图片描述

{"code":0,"data":{"accessToken":"eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiI5YTI2ZjVmMS1jYmQyLTQ3M2QtODJkYi0xZDZkY2Y0NTk4ZjgiLCJyb2xlcy1pbmZvcy1rZXkiOlsiYWRtaW4iXSwiaXNzIjoid3oub3JnLmNuIiwicG93ZXIta2V5IjpbInN5czp1c2VyOmFkZCIsInN5czp1c2VyOmxpc3QiLCJzeXM6dXNlcjp1cGRhdGUiLCJzeXM6dXNlcjpkZWxldGUiXSwiand0LXVzZXItbmFtZS1rZXkiOiJhZG1pbiIsImV4cCI6MTYxMTkxNTIyOCwiaWF0IjoxNjExOTA4MDI4fQ.js6geGE1BwEtfoP_vsOyau_KN5yZi6cTUQ37t3_Z3bU","id":"9a26f5f1-cbd2-473d-82db-1d6dcf4598f8","refreshToken":"eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiI5YTI2ZjVmMS1jYmQyLTQ3M2QtODJkYi0xZDZkY2Y0NTk4ZjgiLCJpc3MiOiJ3ei5vcmcuY24iLCJqd3QtdXNlci1uYW1lLWtleSI6ImFkbWluIiwiZXhwIjoxNjExOTM2ODI4LCJpYXQiOjE2MTE5MDgwMjh9.ORo3SopB4IT_hEnjRVhGlPwo-dG63OkFeJdwLzuCkvI"},"msg":"操作成功"}

测试分页查询接口,传入accessToken和必要信息

在这里插入图片描述

操作成功,结果如下
在这里插入图片描述

测试Token刷新

取得登录时获取的refreshToken在请求体中传入

eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiI5YTI2ZjVmMS1jYmQyLTQ3M2QtODJkYi0xZDZkY2Y0NTk4ZjgiLCJpc3MiOiJ3ei5vcmcuY24iLCJqd3QtdXNlci1uYW1lLWtleSI6ImFkbWluIiwiZXhwIjoxNjExOTM2ODI4LCJpYXQiOjE2MTE5MDgwMjh9.ORo3SopB4IT_hEnjRVhGlPwo-dG63OkFeJdwLzuCkvI

在这里插入图片描述

返回结果中,如果refreshToken未过期但距离过期时间小于2小时,那么返回结果中将会多出一个refreshToken,此refreshToken,此时原来的refreshToken被覆盖,以后只能使用当前新返回的refreshToken,此处我们refreshToken没有即将过期,所以没有返回它.

此时返回字符中的accessToken就是刷新后的accessToken,以后前端让用户免登录状态访问就用此accessToken

{"code":0,"data":{"accessToken":"eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiIxIiwicm9sZXMtaW5mb3Mta2V5IjpbInRlc3QiXSwiaXNzIjoid3oub3JnLmNuIiwicG93ZXIta2V5IjpbInN5czp1c2VyOmFkZCJdLCJqd3QtdXNlci1uYW1lLWtleSI6ImFkbWluIiwiZXhwIjoxNjExOTE1NDg2LCJpYXQiOjE2MTE5MDgyODZ9.rg5NAbxytyrJ_9rGX7-ACcUtAjFQTNYvsZrqMKBZLVk","id":"1"},"msg":"操作成功"}

如果refreshToken过期了,那么将会返回一个错误信息提示用户重新登录.

书山有路勤为径,学海无涯苦作舟.
  • 8
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
Spring Boot是一个开源的Java框架,用于构建独立的、可执行的、生产级的Spring应用程序。它极大地简化了Spring应用程序的搭建和部署过程,提供了一整套开箱即用的特性和插件,极大地提高了开发效率。 Shiro是一个强大且灵活的开源Java安全框架,提供了身份验证、授权、加密和会话管理等功能,用于保护应用程序的安全。它采用插件化的设计,支持与Spring等常用框架的无缝集成,使开发者能够轻松地在应用程序中添加安全功能。 JWT(JSON Web Token)是一种用于在客户端和服务端之间传输安全信息的开放标准。它使用JSON格式对信息进行包装,并使用数字签名进行验证,确保信息的完整性和安全性。JWT具有无状态性、可扩展性和灵活性的特点,适用于多种应用场景,例如身份验证和授权。 Redis是一个开源的、高性能的、支持多种数据结构的内存数据库,同时也可以持久化到磁盘中。它主要用于缓存、消息队列、会话管理等场景,为应用程序提供高速、可靠的数据访问服务。Redis支持丰富的数据类型,并提供了强大的操作命令,使开发者能够灵活地处理各种数据需求。 综上所述,Spring Boot结合ShiroJWTRedis可以构建一个安全、高性能的Java应用程序。Shiro提供了强大的安全功能,包括身份验证和授权,保护应用程序的安全JWT用于安全传输信息,确保信息的完整性和安全性;Redis作为缓存和持久化数据库,提供了高速、可靠的数据访问服务。通过使用这些技术,开发者能够快速、高效地构建出符合安全和性能需求的应用程序。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

君去何方

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

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

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

打赏作者

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

抵扣说明:

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

余额充值