# WebNormal **Repository Path**: plus_plus/WebNormal ## Basic Information - **Project Name**: WebNormal - **Description**: No description available - **Primary Language**: Java - **License**: AFL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2019-03-23 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 搭建SpringSecurity学习环境 ## 使用Spring-boot 2.xx版本进行搭建 NormalPro-pom ```maven io.spring.platform platform-bom Cairo-SR7 pom import org.springframework.cloud spring-cloud-dependencies Edgware.SR5--> Finchley.SR2 pom import ``` 其中打注释的地方是spring-boot-1.5的版本,在NormalPro-browser里面写上spring-session依赖(用于集群session)不会报错,但是我想使用spring-boot-2.xx的版本,但是依照上面的配置后,NormalPro-browser里面的spring-session会报错,所以我就把NormalPro-browser里面的spring-session注释掉了,尝试使用2.xx的版本在集群session时,是否报错。 NormalPro-browser ```maven cn.domarvel NormalPro-core 1.0-SNAPSHOT ``` 另外,在NormalPro-pom里面写上spring-boot的parent控制spring版本,在运行时会报错。 ## spring-boot-starter-jdbc包的作用: 转载地址:https://www.jianshu.com/p/2c01051fbf9f ```maven org.springframework.boot spring-boot-starter-jdbc ``` > 主要给我们提供了三个功能,第一个就是对数据源的装配,第二个就是提供一个jdbc Template简化我们的使用,第三个就是事务 ### 数据源的装配 看个demo,加入数据库驱动,和`spring-boot-starter-jdbc`依赖: ```maven org.springframework.boot spring-boot-starter-web mysql mysql-connector-java org.springframework.boot spring-boot-starter-jdbc ``` 配置数据库连接: ```java spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test spring.datasource.username=root spring.datasource.password=root ``` 以上步骤springboot会自动装配数据源DataSource和JDBC工具了JdbcTemplate 启动类: ```java package com.zhihao.miao; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.jdbc.core.JdbcTemplate; import javax.sql.DataSource; import java.sql.Connection; @SpringBootApplication public class Application { public static void main(String[] args) throws Exception{ ConfigurableApplicationContext context = SpringApplication.run(Application.class,args); DataSource ds = context.getBean(DataSource.class); System.out.println(ds.getClass().getName()); //默认的使用的是tomcat的数据源 Connection connection = ds.getConnection(); System.out.println(connection.getCatalog()); //test System.out.println(context.getBean(JdbcTemplate.class)); connection.close(); } } ``` 启动打印: ![img](md-README-imgs/5225109-de340a458b53f07e.webp) 发现加入数据库驱动,和`spring-boot-starter-jdbc`依赖和配置了数据库的信息之后自动装配的是`org.apache.tomcat.jdbc.pool.DataSource`。也主动装配了JdbcTemplate这个类。 那么springboot默认支持哪些数据源呢?可以看`DataSourceAutoConfiguration`源码, ![img](md-README-imgs/5225109-c524d0683a16c5ac.webp) 默认支持tomcat-jdbc,Hikari,dbcp,dbcp2,Generic这五种数据源。那么怎么装配这些数据源呢?因为我们知道springboot在默认情况下装配的是tomcat-jdbc数据源,比如我们自己配置一个Hikari数据源。 配置方式有二种,第一种是加入相关数据源的依赖,并且排除tomcat的数据源依赖, ```maven com.zaxxer HikariCP org.springframework.boot spring-boot-starter-web mysql mysql-connector-java org.springframework.boot spring-boot-starter-jdbc org.apache.tomcat tomcat-jdbc ``` ![img](md-README-imgs/5225109-df24fc9f3b85481b.webp) 第二种就是加入相关数据源依赖并在配置文件指定默认的数据源, ```maven com.zaxxer HikariCP org.springframework.boot spring-boot-starter-web mysql mysql-connector-java org.springframework.boot spring-boot-starter-jdbc ``` 在application.properties中指定数据源: ```java spring.datasource.type=com.zaxxer.hikari.HikariDataSource ``` ![img](md-README-imgs/5225109-0fe263a3b0bca6b8.webp) 对于springboot默认支持的五种数据源,我们只要将其依赖加入并且进行排除默认的tomcat数据源(或者使用配置文件,如上图所示就能使用自己的数据源了),那么如果使用springboot默认不支持的数据源呢,比如阿里的druid数据源 也有二种方式,第一种直接加入依赖,并在配置文件中指定数据源类型 ```maven com.alibaba druid 1.0.14 ``` application.properties ```java spring.datasource.type=com.alibaba.druid.pool.DruidDataSource ``` ![img](md-README-imgs/5225109-c6a884b65880b111.webp) 第二种方式也是加入相应的数据源依赖, ```maven com.alibaba druid 1.0.14 ``` 修改启动类: ```java @SpringBootApplication public class Application { @Autowired private Environment environment; @Bean public DataSource dataSource(){ DruidDataSource dataSource = new DruidDataSource(); dataSource.setDriverClassName(environment.getProperty("spring.datasource.driver-class-name")); dataSource.setUrl(environment.getProperty("spring.datasource.url")); dataSource.setUsername(environment.getProperty("spring.datasource.username")); dataSource.setPassword(environment.getProperty("spring.datasource.password")); return dataSource; } public static void main(String[] args) throws Exception{ ConfigurableApplicationContext context = SpringApplication.run(Application.class,args); DataSource ds = context.getBean(DataSource.class); System.out.println(ds.getClass().getName()); //默认的使用的是tomcat的数据源 Connection connection = ds.getConnection(); System.out.println(connection.getCatalog()); //test System.out.println(context.getBean(JdbcTemplate.class)); connection.close(); } } ``` ![img](md-README-imgs/5225109-d833e69eed0624a5.webp) 推荐第二种方式,因为可以定制一些数据源的一些其他信息比如初始化连接,最大连接数,最小连接数等等。 ### Springboot JDBC的事务 > 事务: > > - 要使用@EnableTransactionManagement启用对事务的支持 > - 在需要使用事务的方法上面加上@Transactional > 注意,默认只会对运行时异常进行事务回滚,非运行时异常不会回滚事务。 下面我发现不加@EnableTransactionManagement这个注解事务也是生效的 看一个demo: 定义Controller, ```java @RestController public class GoodController { @Autowired private GoodService goodService; @PostMapping("/addGood") public String addGood(@RequestBody Map> map){ List goodsList = map.get("goodslist"); try { goodService.addGood(goodsList); return "addGood success"; } catch (Exception e) { return "addGood fail"; } } } ``` 定义Service层及其实现, ```java public interface GoodService { void addGood(List goodslist) throws Exception; } ``` ```java @Service("goodService") public class GoodServiceImpl implements GoodService{ private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private JdbcTemplate jdbcTemplate; @Override public void addGood(List goodslist) throws Exception{ for (int i = 0; i < goodslist.size(); i++) { Good good = goodslist.get(i); String sql = "insert into tb_good (good_id,good_name) values" + "('"+good.getGoodId()+"','"+good.getGoodName()+"')"; logger.info(sql); jdbcTemplate.execute(sql); } } } ``` 启动执行测试,执行成功数据库中增加了三条记录 ![img](md-README-imgs/5225109-6806382bd2ae2c44.webp) 修改代码,人为的在增加第二条记录的时候抛出异常,删除上面的三条数据, ```java @Service("goodService") public class GoodServiceImpl implements GoodService{ private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private JdbcTemplate jdbcTemplate; @Transactional public void addGood(List goodslist) throws Exception{ for (int i = 0; i < goodslist.size(); i++) { Good good = goodslist.get(i); String sql = "insert into tb_good (good_id,good_name) values" + "('"+good.getGoodId()+"','"+good.getGoodName()+"')"; logger.info(sql); if("书籍".equals(good.getGoodName())){ throw new NullPointerException(""); } jdbcTemplate.execute(sql); } } } ``` 再去测试,发现事务生效,我们都知道默认事务回滚运行期异常,我们修改代码,人为的抛出非运行期异常,发现事务并没有回滚, ```java @Service("goodService") public class GoodServiceImpl implements GoodService{ private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private JdbcTemplate jdbcTemplate; @Transactional public void addGood(List goodslist) throws Exception{ for (int i = 0; i < goodslist.size(); i++) { Good good = goodslist.get(i); String sql = "insert into tb_good (good_id,good_name) values" + "('"+good.getGoodId()+"','"+good.getGoodName()+"')"; logger.info(sql); if("书籍".equals(good.getGoodName())){ throw new FileNotFoundException(""); } jdbcTemplate.execute(sql); } } } ``` 那么要使得非运行期异常也回滚,就要使用@Transactional进行相关配置,比如@Transactional(rollbackFor=Exception.class)对所有异常进行回滚不管是运行期还是非运行期异常。 ```java @Service("goodService") public class GoodServiceImpl implements GoodService{ private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private JdbcTemplate jdbcTemplate; @Transactional(rollbackFor=Exception.class) public void addGood(List goodslist) throws Exception{ for (int i = 0; i < goodslist.size(); i++) { Good good = goodslist.get(i); String sql = "insert into tb_good (good_id,good_name) values" + "('"+good.getGoodId()+"','"+good.getGoodName()+"')"; logger.info(sql); if("书籍".equals(good.getGoodName())){ throw new FileNotFoundException(""); } jdbcTemplate.execute(sql); } } } ``` 还可以通过transactionManager对哪些数据源进行回滚(多数据源情况下),propagation配置事务的传播行为,isolation配置事务的隔离级别,timeout事务的超时时间,noRollbackForClassName哪些数据可以不回滚等等。 修改代码 ```java @Service("goodService") public class GoodServiceImpl implements GoodService{ private Logger logger = LoggerFactory.getLogger(getClass()); @Autowired private JdbcTemplate jdbcTemplate; public void addGood(List goodslist) throws Exception{ addGoodreal(goodslist); } @Transactional public void addGoodreal(List goodslist) throws Exception{ for (int i = 0; i < goodslist.size(); i++) { Good good = goodslist.get(i); String sql = "insert into tb_good (good_id,good_name) values" + "('"+good.getGoodId()+"','"+good.getGoodName()+"')"; logger.info(sql); if("书籍".equals(good.getGoodName())){ throw new NullPointerException(""); } jdbcTemplate.execute(sql); } } } ``` 在我们controller层调用addGood的方法上没有加 @Transactional,这时事务就没有回滚。 ## 第一个SpringSecurity Hello World application.properties ```properties # 因为spring-boot-jdbc开始要初始化数据库连接池,需要连接,所以需要配置下面的东西 spring.datasource.username=root spring.datasource.password=firelang... spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/springsecurity?useUnicode=true&characterEncoding=UTF-8&useServerPrepStmts=true&cachePrepStmts=true&zeroDateTimeBehavior=convertToNull # 集群session相关,还没到那,先关闭session存储 spring.session.store-type=none ``` UserController.java ```java @RestController public class UserController { @GetMapping("/hello") public String hello(){ return "Hello SpringSecurity"; } } ``` ![1553336281024](md-README-imgs/1553336281024.png) 原因是因为,这个是SpringSecurity的默认配置。 我们先把它关掉。 ![1553336529432](md-README-imgs/1553336529432.png) 但是,在Spring-security-starter 2.0.8中,该配置已经过时了(在1.5xx中是可用的) 那就这样配置。 application.properties ```properties spring.security.user.name=user spring.security.user.password=123 ``` 输入用户名密码后,登入成功。 ![1553336939738](md-README-imgs/1553336939738.png) ## 打包SpringBoot程序 ```maven org.springframework.boot spring-boot-devtools true ``` ```maven org.springframework.boot spring-boot-maven-plugin cn.domarvel.Application repackage ``` 打包后的名字 ```maven org.apache.maven.plugins maven-compiler-plugin 3.1 ${maven.compiler.source} ${maven.compiler.target} ${project.build.sourceEncoding} org.springframework.boot spring-boot-maven-plugin cn.domarvel.Application repackage spring-security-demo ``` 开始时打包失败原因: 1.NormalPro-pom 的打包方式是pom 剩下的四个core、app、browser、demo都是jar打包方式。 2.springboot模块打包要配置这两个: ```maven cn.domarvel.Application repackage ``` 然后打包进行。 ![1553340240963](md-README-imgs/1553340240963.png) 运行打包后的jar包。 ![1553340275422](md-README-imgs/1553340275422.png) 第二个是原始包。 java -jar xxx.jar ![1553340305539](md-README-imgs/1553340305539.png) # SpringMVC开发RESTful风格API ![1553340833028](md-README-imgs/1553340833028.png) ![1553340860897](md-README-imgs/1553340860897.png) ![1553340885503](md-README-imgs/1553340885503.png) ![1553341256494](md-README-imgs/1553341256494.png) ![1553340961511](md-README-imgs/1553340961511.png) ## 基础查询 ![1553341314527](md-README-imgs/1553341314527.png) ![1553341026525](md-README-imgs/1553341026525.png) ![1553341446783](md-README-imgs/1553341446783.png) ![1553341378499](md-README-imgs/1553341378499.png) ## 详细查询-@JsonView控制返回到前端的视图数据 ![1553341628752](md-README-imgs/1553341628752.png) ![1553341667639](md-README-imgs/1553341667639.png) ![1553341709335](md-README-imgs/1553341709335.png) 使用正则限制路径参数只能是数字,而不可以是其它参数: ![1553341740355](md-README-imgs/1553341740355.png) 使用@JsonView控制返回到前端的视图数据: ![1553341757784](md-README-imgs/1553341757784.png) @JsonView可以放在get方法上,也可以放在字段上。 ![1553341820136](md-README-imgs/1553341820136.png) ![1553341928037](md-README-imgs/1553341928037.png) ![1553341975013](md-README-imgs/1553341975013.png) 结构优化 ![1553342006290](md-README-imgs/1553342006290.png) 类上面的RequestMapping的URL最终会和方法上面的RequestMapping合并起来作为最终的url映射。 ## 用户创建 ![1553342435392](md-README-imgs/1553342435392.png) @RequestBody的意思就是,把前台传到后台的json解析到Java方法参数上面。 对于日期类型的处理,传入后台和从后台传出,统一使用时间戳的方式。 数据为空校验,需要在bean里面的字段上面打上注解,比如@NotBlank等,这些是hibernate的实现。 @NotBlank能够识别null、空字符串、只含有空格的字符串。 ![1553342557368](md-README-imgs/1553342557368.png) BindingResult这个参数是和@Valid组合使用的,如果不写BindingResult这个参数,只写了@Valid这个注解那么当验证不通过时,方法体是不会被执行的。 同时写了@Valid注解和BindingResult这个参数,那么不论是否验证出错都会执行方法体。 ![1553342617402](md-README-imgs/1553342617402.png) 这个是写了@Valid和BindingResult的返回值,后台输出:密码不能为空 ![1553342669213](md-README-imgs/1553342669213.png) 只写了@Valid注解: ![1553342693966](md-README-imgs/1553342693966.png) ![1553342718385](md-README-imgs/1553342718385.png) ## 开发用户信息修改和删除 ![1553342752958](md-README-imgs/1553342752958.png) 校验参考链接:https://www.cnblogs.com/cjsblog/p/8946768.html 校验分组:https://blog.csdn.net/u013310119/article/details/51760245 @Valid和@Validated区别:https://blog.csdn.net/qq_27680317/article/details/79970590 @RequestParam或@PathVariable的数据校验:https://blog.csdn.net/onupway/article/details/78367629 ### 常用校验注解 ![1553342845294](md-README-imgs/1553342845294.png) ![1553342858215](md-README-imgs/1553342858215.png) ### 自定义错误消息 像这样自定义错误消息: ![1553342880324](md-README-imgs/1553342880324.png) ### 自定义校验注解 自定义校验注解: 实现接口ConstraintValidator: 该类因为实现了ConstraintValidator,所以会被Spring容器自动装载,并且在当前类里可以通过@Autowired注入其它bean ![1553342950292](md-README-imgs/1553342950292.png) ![1553342990576](md-README-imgs/1553342990576.png) 然后就可以使用了: ![1553343028338](md-README-imgs/1553343028338.png) ### 用户信息修改 ![1553343287663](md-README-imgs/1553343287663.png) ![1553343317496](md-README-imgs/1553343317496.png) 并且校验注解实现类只会初始化一次: ![1553343376544](md-README-imgs/1553343376544.png) ### 删除服务 ![1553343632714](md-README-imgs/1553343632714.png) ![1553343654881](md-README-imgs/1553343654881.png) # 分组校验 ![1553343719394](md-README-imgs/1553343719394.png) ![1553343748336](md-README-imgs/1553343748336.png) # @RequestParam和@PathVariable校验 校验通过,执行方法体,校验不通过,向外报错,所以需要一个全局错误处理: ![1553343963977](md-README-imgs/1553343963977.png) ![1553344037570](md-README-imgs/1553344037570.png) ![1553344070027](md-README-imgs/1553344070027.png) # SpringBoot默认的错误处理机制 ![1553344233248](md-README-imgs/1553344233248.png) 默认错误处理机制: 不同平台访问同一个链接返回的错误信息不一样,比如浏览器访问到不存在链接接收到的是404页面, 而app或者RESTful测试工具返回的是一段json字符串。 其中的原理就是(produces的作用是判断请求头的Accept中是否有该属性,如果有就执行该处理方法,如果没有就不执行该处理方法): ![1553345226853](md-README-imgs/1553345226853.png) # 自定义浏览器异常页面处理 ![1553345244227](md-README-imgs/1553345244227.png) # 捕获异常处理 ![1553345274970](md-README-imgs/1553345274970.png) # RESTful API的拦截 ## 过滤器(Filter) 过滤器配置: 创建一个过滤器: ![1553345620529](md-README-imgs/1553345620529.png) 让过滤器、Servlet、Listener等自动被SpringBoot装配。 在配置类上面写上@ServletComponentScan ![1553345643147](md-README-imgs/1553345643147.png) ## 拦截器(Interceptor) 拦截器配置: 之所以有拦截器,是因为Filter过滤器是没办法获取当前请求是请求哪儿个控制器的,而拦截器知道。 实现拦截器还可以继承HandlerInterceptorAdapter preHandle:控制器处理方法之前执行 postHandle:控制器处理方法正常执行之后执行,也就是控制器处理方法不抛出异常才会执行 afterCompletion:控制器处理方法不管是否正常执行都会执行的一个方法,该方法参数里面的Exception就是控制器处理器方法抛出的异常,该异常如果被异常处理器拦截了或者没有异常,那么afterCompletion方法参数里面的Exception就是null。 ![1553345922231](md-README-imgs/1553345922231.png) 让拦截器生效: ![1553345964123](md-README-imgs/1553345964123.png) ## 切片(Aspect) ![1553346022930](md-README-imgs/1553346022930.png) 对于切片拦截,我们直接使用@Around就行,并且增强也支持多个增强共存,多个增强也可以设置执行顺序,通过实现Ordered接口(顺序号小的先织入)。 ![1553346100661](md-README-imgs/1553346100661.png) ![1553346175377](md-README-imgs/1553346175377.png) 环绕方法的返回值为被增强方法的返回值。 案例(对同一个控制器执行方法配置多个切面增强): ![1553346243851](md-README-imgs/1553346243851.png) ![1553346296022](md-README-imgs/1553346296022.png) 输出结果: ![1553346316748](md-README-imgs/1553346316748.png) ## 三种拦截方式起作用顺序 ![1553346408949](md-README-imgs/1553346408949.png) 正向执行:一个请求到达后,执行顺序是===>Filter拦截、Interceptor拦截、Aspect(切片)拦截。 控制器处理器方法抛出异常执行顺序:Aspect首先捕获到异常,如果Aspect不处理异常,向外抛出,那么就是ControllerAdvice拦截到异常,如果ControllerAdvice不处理异常,那么就是Interceptor的afterCompletion得到异常,如果Interceptor不处理,那么就是Tomcat向用户抛出异常。 ## 可以通过设置属性配置是否通过CGLIB或者JDK动态代理: ![1553346584463](md-README-imgs/1553346584463.png) 学习了上面知识后,要注意区别Filter、Interceptor、切片的拦截方式。 Filter只拦截请求;Interceptor除了拦截请求,还知道是哪儿个控制器执行。;切片除了上面两个,还知道控制器的参数。 # 文件上传和下载 ## 文件上传 ![1553346787201](md-README-imgs/1553346787201.png) ![1553346796827](md-README-imgs/1553346796827.png) ## 文件下载 因为路径参数无法写"."符号表达文件格式,所以,通过get后缀参数获取。 ![1553346881595](md-README-imgs/1553346881595.png) ![1553346891708](md-README-imgs/1553346891708.png) # 异步处理REST服务-使用多线程提高REST服务性能 ![1553346996448](md-README-imgs/1553346996448.png) ## 使用Runable 异步处理Rest服务 ### 单机异步 案例: 这是以前同步时的请求方式: ![1553347064379](md-README-imgs/1553347064379.png) ![1553347080913](md-README-imgs/1553347080913.png) 服务器输出,可见,是同一个线程处理的任务,开始和结束相差1s: ![1553347109107](md-README-imgs/1553347109107.png) 单机运行时的多线程: ![1553347157492](md-README-imgs/1553347157492.png) 对于拦截该请求路径的Filter或者该路径的Servlet需要配置以下内容,不然要报错: https://blog.csdn.net/qq_23212697/article/details/53586953 ![1553347206872](md-README-imgs/1553347206872.png) 前台访问是看不到什么区别的: ![1553347231240](md-README-imgs/1553347231240.png) 可以看到主线程方法直接返回执行时间很小很小,而耗时的都由副线程执行,并且正常返回,执行时间为1s。 副线程的执行线程为MvcAsync4。 ![1553347284233](md-README-imgs/1553347284233.png) 目前副线程是每次请求都会新建的,所以为了节省资源,我们可以使用线程池来优化处理: 参考链接: 线程池配置=>https://www.jianshu.com/p/21ff7a329a3e 线程池方法含义=>https://blog.csdn.net/sunct/article/details/80281116 ![1553347347463](md-README-imgs/1553347347463.png) 可见,线程前缀已经变成了我们线程池设置的了: ![1553347365476](md-README-imgs/1553347365476.png) ## 使用DeferredResult异步处理Rest服务 ### 多机异步 上面演示的是单机异步,下面演示多机异步: ![1553347420863](md-README-imgs/1553347420863.png) 模拟消息队列: ![1553347615253](md-README-imgs/1553347615253.png) 创建一个消息返回结果字典: ![1553347648231](md-README-imgs/1553347648231.png) 当服务器一启动就监听消息队列: ![1553347771164](md-README-imgs/1553347771164.png) DeferredResult作为返回值时是,当里面一有值就响应前台: ![1553347936164](md-README-imgs/1553347936164.png) 运行效果: ![1553347950989](md-README-imgs/1553347950989.png) 后台运行结果: ![1553347978964](md-README-imgs/1553347978964.png) 其中Thread-56和35是模拟消息队列和监听器。 ## 死循环里面,线程休眠有利于CPU性能释放 ![1553348019169](md-README-imgs/1553348019169.png) ## 注册异步拦截器 不仅支持异步Controller还支持异步的拦截器: ![1553348107432](md-README-imgs/1553348107432.png) # 与前端开发并行工作 ## 使用swagger自动生成html文档 引入Swagger: ```maven io.springfox springfox-swagger2 ${springfox-swagger.version} io.springfox springfox-swagger-ui ${springfox-swagger.version} ``` 在注解配置类里面开启注解: ![1553412192550](md-README-imgs/1553412192550.png) 访问api说明文档链接:http://localhost:8080/swagger-ui.html Swagger的API说明常用注解:https://blog.csdn.net/qq_35813653/article/details/84288653 ### Swagger常用注解 #### @ApiOperation value是说该方法是干什么的、response是说当前API方法返回什么数据类型。 ![1553412271884](md-README-imgs/1553412271884.png) 可以看到效果: ![1553412293235](md-README-imgs/1553412293235.png) #### @ApiModelProperty 标注该属性是什么意思。 ![1553412331448](md-README-imgs/1553412331448.png) #### @ApiParam 标注方法某个参数是干什么的。 ![1553412366406](md-README-imgs/1553412366406.png) 效果如下: ![1553412383779](md-README-imgs/1553412383779.png) ## 使用WireMock快速伪造RESTful服务 ### 下载WireMock WireMock服务器下载地址:http://wiremock.org/docs/running-standalone/ ![1553412408738](md-README-imgs/1553412408738.png) ### 启动WireMock 启动命令: ```sh java -jar wiremock-standalone-2.21.0.jar --port 8081 ``` ### 使用WireMock构建RESTful服务 IDEA代码客户端导入jar包: ```maven com.github.tomakehurst wiremock-standalone ``` 通过WireMock伪造RESTful服务: ![1553412555071](md-README-imgs/1553412555071.png) ```java public class WireMockMain { public static void main(String[] args) throws IOException { configureFor(8081); // WireMock客户端连接端口 removeAllMappings(); // 移除当前WireMock所有映射URL mock("GET", "/user/1", "UserDetail"); mock("GET", "/user", "UserList"); } private static void mock(String httpMethod, String urlPath, String returnFileName) throws IOException { ClassPathResource classPathResource = new ClassPathResource("mock/response/"+returnFileName+".json"); String content = FileUtils.readFileToString(classPathResource.getFile(), "UTF-8"); stubFor(request(httpMethod, urlPathEqualTo(urlPath)).willReturn(aResponse() .withBody(content) .withStatus(200) .withHeader("content-type", "application/json;charset=UTF-8"))); } } ``` 资源路径是这样的: ![1553412599088](md-README-imgs/1553412599088.png) 测试请求结果: ![1553412611321](md-README-imgs/1553412611321.png) ![1553412621245](md-README-imgs/1553412621245.png) # SpringSecurity开发基于表单的认证 ## 尝试基本的SpringSecurity认证 ### SpringSecurity核心功能 - 认证(你是谁) - 授权(你能干什么) - 攻击防护(防止伪造身份) ### 开启SpringSecurity认证 Spring-security-1.xx 需要开启SpringSecurity ![1553413029154](md-README-imgs/1553413029154.png) Spring-Security-2.xx只需要这样配置 ![1553413095522](md-README-imgs/1553413095522.png) 因为我们的项目是开发出可重用的认证框架,所以我们的SpringSecurity配置就放在browser里面,注意主项目demo的主配置文件和browser里面的BrowserSecurityConfig配置文件位置,位置必须是当发布成一个jar包后,主配置文件能够扫描到SpringSecurity配置文件,否则会不生效。 ![1553418760477](md-README-imgs/1553418760477.png) BrowserSecurityConfig的配置如下: 覆盖了Adapter里面的默认配置。 ![1553413190782](md-README-imgs/1553413190782.png) 根据配置,所有请求URL都被拦截了: 效果如下: ![1553413224609](md-README-imgs/1553413224609.png) ## SpringSecurity基本原理 ![1553413363973](md-README-imgs/1553413363973.png) 基本原理就是:FilterSecurityInterceptor是最后的检察官,它的放行条件就是通过 ![1553413404440](md-README-imgs/1553413404440.png) 上面的代码进行配置的,如果覆盖WebSecurityConfigurerAdapter的configure(HttpSecurity http)方法后不写放行条件,那么就会直接访问到REST API资源。当FilterSecurityInterceptor配置成上面的内容后,访问任意REST资源,如/user/1,FilterSecurityInterceptor就会检查到还没有进行【登录认证】,就会抛出一个未认证异常,ExcptionTranslationFilter就会捕获到这个异常,根据不同异常进行处理,因为是未认证异常,ExceptionTranslationFilter就会重定向页面到UsernamePasswordAuthenticationFilter或者BasicAuthenticationFilter上(这是根据上面的配置跳转的formLogin或httpBasic),也就是登录页面上,当登录成功后,就再次重定向到原始的请求资源上面,也就是/user/1。 ## 实现用户名+密码认证 ### 自定义比对用户名和密码进行登录 - 处理用户信息获取逻辑 UserDetailsService - 处理用户校验逻辑 UserDetails - 处理密码加密解密 PasswordEncoder SpringSecurity配置还是那样: ![1553413856600](md-README-imgs/1553413856600.png) 新写的代码: ![1553414009470](md-README-imgs/1553414009470.png) ```java // 加入到Spring的Bean容器后,就自动成了SpringSecurity的默认用户获取实现。 // 因为SpringSecurity默认是没有默认用户获取实现的,当没有UserDetailsService的Bean, // UsernamePasswordAuthenticationFilter就通过比对自动生成的密码进行匹配 @Component public class MyUserDetailService implements UserDetailsService { /** * 意思就是如果通过username查询到用户就返回UserDetails,如果没有查询到就抛出UsernameNotFoundException * @param username * @return * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 这里通过用户名查询数据库密码进行普通比对,最后一个参数是当前用户拥有的权限列表。 return new User(username, "123456", AuthorityUtils.commaSeparatedStringToAuthorityList("ADMIN, USER")); } } ``` 执行效果: ![1553414064796](md-README-imgs/1553414064796.png) 只要输入任何用户名,密码为123456就能登录成功,并且获得ADMIN、USER的权限。 > 注意:如果Spring容器里面有多个UserDetailService的实现类,那么SpringSecurity将不会使用Spring容器里面的所有UserDetailService实现,而是使用默认的账户登录(user/自动生成的密码) ### spring-security-started 2.xx不配置密码加密解密会报错 在spring-security-started 2.xx中,默认是没有密码加密解密的,所以当上面的东西配置完成后会报错,而spring-security-started 1.xx中却不会报错。 ![1553416626955](md-README-imgs/1553416626955.png) ### 对登录密码进行加密解密处理 配置SpringSecurity的推荐密码加密解密实现:BCryptPasswordEncoder 执行顺序是,当前台传入用户名和密码到后台后,后台先通过UserDetailsService获取指定用户名的用户信息,然后通过调用PasswordEncoder的boolean matches方法比对是否匹配然后作返回。 ![1553414276534](md-README-imgs/1553414276534.png) 用户信息获取逻辑改写: ![1553414369456](md-README-imgs/1553414369456.png) 请求任意链接,正常登录后输出内容: ![1553414384733](md-README-imgs/1553414384733.png) ### UserDetails的高级特性 ![1553414440787](md-README-imgs/1553414440787.png) isAccountNonExpired:是否账户没有过期或者失效。 isAccountNonLocked:是否账户没有冻结或者锁定。 isCredentialsNonExpired:是否账户的密码没有过期。 isEnabled:账户是否可用,通常企业对该方法的业务应用是账户是否被删除。 如果账户是否不可用或者被冻结那么SpringSecurity将会抛出异常。 ### PasswordEncoder的两个方法含义 ![1553414513677](md-README-imgs/1553414513677.png) encode:加密字符串使用,一般场景是当用户注册时,通过该方法加密字符串后存入数据库。 matches:密码进行匹配,成功返回true,失败返回false。 ### 个性化用户认证流程 #### 浏览器或者其它请求平台能够有不同的响应和自定义登录页面 > 要处理的问题是: > > - 浏览器或者其它请求平台能够有不同的响应 浏览器请求时,如果需要登录验证就跳转到登录页面。app或其它平台请求时,如果需要登录验证就返回还未验证的json信息。 因为是关于浏览器相关的配置,我们统一放在: ![1553418815098](md-README-imgs/1553418815098.png) 建立一个统一内容返回的类: ![1553418833497](md-README-imgs/1553418833497.png) ![1553418868888](md-README-imgs/1553418868888.png) ![1553418880554](md-README-imgs/1553418880554.png) > loginProcessingUrl方法中,写什么,登录的POST提交处理请求URL就是多少,里面的实现我们不用写。 到这里,我们就实现了不同平台请求登录页面就有不同的返回。 注意: 如果重新设置了loginProcessingUrl,那么就必须设置下面的内容才能进行登入,否则一直登录都登录不进去。 ![1553422292044](md-README-imgs/1553422292044.png) #### 抽取可配置信息作为配置类 ![1553419613611](md-README-imgs/1553419613611.png) 所以我们统一把配置类放在core模块中: ![1553419502893](md-README-imgs/1553419502893.png) 创建: 除了能够自定义登录页面,当用户不配置登录页面时,我们提供一个默认浏览器登录页面: 属性写上"/login.html" ![1553419682851](md-README-imgs/1553419682851.png) 创建: ![1553419701000](md-README-imgs/1553419701000.png) 让几个自定义配置类生效(让SecurityProperties自动读取application.properties里面的配置,并且生成一个Bean被Spring容器管理): ![1553419737230](md-README-imgs/1553419737230.png) 为了让我们的配置类属性在application.properties中有提示,我们新建META-INF/spring-configuration-metadata.json文件: ![1553419759379](md-README-imgs/1553419759379.png) 里面的内容如下: ```json { "hints": [], "groups": [ { "sourceType": "cn.domarvel.security.core.properties.SecurityProperties", "name": "随便乱取", "type": "cn.domarvel.security.core.properties.SecurityProperties" } ], "properties": [ { "sourceType": "cn.domarvel.security.core.properties.BrowserProperties", "name": "security.browser.loginPage", "defaultValue": "/login.html", "description": "Context path used to handle the remote connection.", "type": "java.lang.String" } ] } ``` 在browser模块的resources/static里面加入默认的/login.html页面: ![1553419820236](md-README-imgs/1553419820236.png) ```html 登录

用户登录页面

``` 引用登录页面配置属性: ![1553419859659](md-README-imgs/1553419859659.png) ![1553419872646](md-README-imgs/1553419872646.png) 访问项目任何资源后,访问后的运行结果: 可以看到,已经显示了我们的login.html页面 ![1553419915711](md-README-imgs/1553419915711.png) 当我们在demo项目的application.properties里面配置自定义登录页面后: 在demo项目里面的static文件里面新建登录页面: ![1553419950040](md-README-imgs/1553419950040.png) 配置: ![1553419964540](md-README-imgs/1553419964540.png) ![1553419975278](md-README-imgs/1553419975278.png) 运行结果为: 可以看到已经跑到了我们配置的登录页面去了。login-new.html ![1553419993633](md-README-imgs/1553419993633.png) #### 自定义登录成功/失败处理 ![1553426117775](md-README-imgs/1553426117775.png) > 处理这两个需求,我们通常是继承它们的子类: > AuthenticationSuccessHandler的子类:SavedRequestAwareAuthenticationSuccessHandler > AuthenticationFailureHandler的子类:SimpleUrlAuthenticationFailureHandler 在browser模块中新建两个类,用来处理登录成功和登录失败的处理。 但是有个需求是,浏览器要返回HTML,其它平台要返回json。 ![1553426193600](md-README-imgs/1553426193600.png) 处理成功返回: ![1553426211772](md-README-imgs/1553426211772.png) 在browser模块的的控制器里面加入平台返回处理代码: ![1553426261498](md-README-imgs/1553426261498.png) ![1553426282861](md-README-imgs/1553426282861.png) 处理失败返回: ![1553426304142](md-README-imgs/1553426304142.png) ![1553426320979](md-README-imgs/1553426320979.png) 注意上面处理平台兼容的代码中,有没有登录时就会访问的链接,所以我们需要这些链接不被拦截: ![1553426356551](md-README-imgs/1553426356551.png) ![1553435431542](md-README-imgs/1553435431542.png) 运行后效果: ????因为默认 BasicErrorController返回的text/html编码是ISO8859-1的,不支持中文,而????是中文的“用户名或密码错误”。 ![1553426388310](md-README-imgs/1553426388310.png) ![1553426401567](md-README-imgs/1553426401567.png) #### 优化登录失败重定向 经过试验,在这个版本中 ![1553505570108](md-README-imgs/1553505570108.png) SpringSecurity的默认登录错误是把错误信息保存在session中,key为SPRING_SECURITY_LAST_ERROR。 当登录成功后,session中的SPRING_SECURITY_LAST_ERROR错误信息才被清除。 而我目前的实现是 ![1553505732147](md-README-imgs/1553505732147.png) ```java @PostMapping(value = "/failure", produces = {"text/html"}) public String browserAuthenticationFailure(HttpServletRequest request, RedirectAttributes redirectAttributes){ AuthenticationException authenticationException = (AuthenticationException)request .getAttribute("authenticationException"); redirectAttributes.addFlashAttribute("loginError", authenticationException.getMessage()); // https://blog.csdn.net/m0_37355951/article/details/74531253 return "redirect:/authentication/require?error"; } ``` 重定向的错误信息通过RedirectAttributes保存,通过addFlashAttribute添加数据。 > Spring提供RedirectAttributes的模型,其实Spring本质也是存入session,如果我们自己存的话需要我们管理,Spring它自会在用完之后销毁这个对象 上面内容参考链接:https://blog.csdn.net/m0_37355951/article/details/74531253 登录页面为 ```html 登录

用户登录页面


``` 目录结构: ![1553505999229](md-README-imgs/1553505999229.png) 运行后效果 ![1553506040838](md-README-imgs/1553506040838.png) 另外:在这个版本中 ![1553505570108](md-README-imgs/1553505570108.png) 登录失败后重定向的xxxx?error页面,如果带上?error参数能够看到错误信息,如果去掉?error参数就看不到错误信息,加上又能看到。 ### 认证流程源码级别详解 #### 认证处理流程说明 ![1553516693950](md-README-imgs/1553516693950.png) #### 认证结果如何在多个请求之间共享 ![1553516723170](md-README-imgs/1553516723170.png) ![1553516734216](md-README-imgs/1553516734216.png) 认证结果如何在多个请求之间共享??? 该功能的功能实现主要归功于SecurityContextHolder,它的功能实现靠ThreadLocal: ![1553516902102](md-README-imgs/1553516902102.png) > 上面的认证结果(authResult)是一个Authentication的子类,因为SpringSecurity默认是通过UsernamePasswordAuthenticationToken认证的,所以authResult的实际类型是UsernamePasswordAuthenticationToken 当认证成功后SecurityContextHolder会把认证结果装进去,也就是把认证结果装在当前线程中,看上图,第一个黄色部分,SecurityContextPersistenceFilter,当请求出站时SecurityContextPersistenceFilter会把当前线程中的认证结果放进当前Session中,当入站时SecurityContextPersistenceFilter会把当前Session中的认证结果放到当前线程中,也就是SecurityContextHolder中。 #### 获取认证用户信息 在UserController中写上下面这个方法: ```java @GetMapping("/me") public Object me(){ return SecurityContextHolder.getContext().getAuthentication(); } ``` 看了上面代码,你就应该能确定,认证信息确实是在当前线程中,因为通过调用类的静态方法就能够获取到当前用户的认证信息。(如果不是放在线程中,而是放在静态变量中,那么多个用户共享同一个变量如何得到不同的权限???) 访问结果如下: ![1553517139607](md-README-imgs/1553517139607.png) 上面的代码获取当前用户信息有点麻烦,有没有简单的方法?当然有: SpringMVC会自动到Spring的SecurityContext里面找。 ![1553517177557](md-README-imgs/1553517177557.png) 另外,如果我们只想要principal里面的信息,也就是UserDetails的信息,那么我们就这么做(UserDetails中不包含用户的登录IP信息): ![1553517301645](md-README-imgs/1553517301645.png) ![1553517350624](md-README-imgs/1553517350624.png) 最后为了验证SecurityContext中的验证结果和session中的是否一样(也就是验证上述的ThreadLocal原理),我们遍历Session中的属性得到如下内容: ![1553517400501](md-README-imgs/1553517400501.png) ![1553517496033](md-README-imgs/1553517496033.png) 输出Session的中SPRING_SECURITY_CONTEXT内容: ![1553517523723](md-README-imgs/1553517523723.png) ![1553517541997](md-README-imgs/1553517541997.png) 结果比对可见,完全一样,SecurityContextHolder原理所诉正确。 ## 实现图形验证码功能 ### 开发生产图形验证码接口 #### 生成图形验证码 ![1553518025517](md-README-imgs/1553518025517.png) 因为验证码的功能不管是在浏览器中还是其它平台,比如APP中,都会使用到,所以我们把验证码的代码写在core模块当中: ![1553518170810](md-README-imgs/1553518170810.png) 定义ImageCode基础dto类: dto类里面用的时间类是LocalDateTime,重写了构造方法。 写了验证当前ImageCode是否过期的方法,isExpired() ![1553518217884](md-README-imgs/1553518217884.png) 定义验证码生成器接口: 因为需要实现 【请求级别】配置验证码生成,所以需要request ![1553518244481](md-README-imgs/1553518244481.png) 实现验证码生成接口: ```java public class ImageCodeGenerator implements ValidateCodeGenerator { private SecurityProperties securityProperties; public void setSecurityProperties(SecurityProperties securityProperties) { this.securityProperties = securityProperties; } @Override public ImageCode generate(HttpServletRequest request) { int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getValidateCode().getImage().getWidth()); int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getValidateCode().getImage().getHeight()); //生成随机数,此过程设置了vecode String code = getRandomText(); //生成缓存图片 BufferedImage image=new BufferedImage(width, height, BufferedImage.TYPE_INT_BGR); //获取画笔 Graphics2D graphics2d=(Graphics2D)image.getGraphics(); //设置画笔颜色 graphics2d.setColor(backColor); //用该颜色当做背景色 graphics2d.fillRect(0, 0, width, height); double separator = width/(getVerifyCodeCount()+1)*1.0; //开始在缓存图片上画验证码 for(int j=1;j<=getVerifyCodeCount();j++){ int x=(int)((j-1)*separator)+(int) separator; graphics2d.setColor(getRandomColor()); int fontSize = getRandomSize(); graphics2d.setFont(new Font(getRandomFont(), getRandomStyle(), fontSize)); int y = (height-fontSize)/2+fontSize; graphics2d.drawString(code.charAt(j-1)+"", x, y); } //画干扰线 setLine(graphics2d, width, height); return new ImageCode(image, code, securityProperties.getValidateCode().getImage().getExpireIn()); } private Random random=new Random();//随机类,供以后使用 private String fonts[]={"宋体","黑体","华文楷体","微软雅黑","楷体_GB2312"};//默认的验证码字体样式,这些样式在生成验证码的每一位的时候会随机选择使用 private Color backColor=Color.lightGray;//图片的背景颜色,默认为淡灰色 public String getOrain() { return securityProperties.getValidateCode().getImage().getOriginCode(); } public String[] getFonts() { return fonts; } public void setFonts(String[] fonts) { this.fonts = fonts; } public Integer getVerifyCodeCount() { return securityProperties.getValidateCode().getImage().getLength(); } public Color getBackColor() { return backColor; } public void setBackColor(Color backColor) { this.backColor = backColor; } private int getRandomSize(){ return random.nextInt(3)+18; } //获得字体 private String getRandomFont(){ return fonts[random.nextInt(fonts.length)]; } //获取字体风格 private int getRandomStyle(){ return random.nextInt(4); } //获得随机颜色,但是该颜色有范围的 private Color getRandomColor(){ int rgb[]=new int[3]; for(int i=0;i<3;i++){ rgb[i]=random.nextInt(100); } return new Color(rgb[0],rgb[1],rgb[2]); } //生成随机验证码 private String getRandomText(){ StringBuilder str=new StringBuilder(); for(int i=1;i<=getVerifyCodeCount();i++){ str.append(getOrain().charAt(random.nextInt(getOrain().length()))); } return str.toString(); } //设置验证码干扰线 private void setLine(Graphics2D graphics2d, int width, int height){ int y1,y2; for(int i=1;i<=6;i++){ int temp=height/3;//获取到1/3 y1=random.nextInt(height); y2=random.nextInt(height); graphics2d.setFont(new Font(getRandomFont(),getRandomStyle(),getRandomSize())); graphics2d.setColor(getRandomColor()); graphics2d.drawLine(0, y1, width, y2); } } //设置验证码噪点 private void setPoint(BufferedImage image, int width, int height){ for(int i=1;i<=300;i++){ int x=random.nextInt(width); int y=random.nextInt(height); image.setRGB(x, y, getRandomColor().getRGB()); } } } ``` 在core的properties包中写上如下代码: ![1553518313011](md-README-imgs/1553518313011.png) ![1553518322611](md-README-imgs/1553518322611.png) ![1553518343805](md-README-imgs/1553518343805.png) 在当前validate.core.controller里面写验证控制器: ![1553518372140](md-README-imgs/1553518372140.png) 配置 ![1553674261652](md-README-imgs/1553674261652.png) 访问结果 ![1553674271478](md-README-imgs/1553674271478.png) ### 在认证流程中加入图形验证码校验 为了实现验证码的验证,根据SpringSecurity的核心验证思想,我们写一个过滤器,当过滤器验证到验证码不正确时抛出AuthenticationException异常,这个是SpringSecurity的统一授权异常类,并且由我们的授权失败Handler来处理这个异常,在这个过滤器里面我们手动写过滤地址,如果验证成功就doFilter,验证失败就抛出异常: 我们实现的异常类为: ```java public class ValidateCodeException extends AuthenticationException { public ValidateCodeException(String msg) { super(msg); } } ``` 用到的其它类: ##### LocalDateTime的使用 参考链接: - https://www.jianshu.com/p/b41e2b8fe28e - https://www.jianshu.com/p/339baad3c8ff ##### LocalDateTime和时间戳相互转化 - https://blog.csdn.net/czx2018/article/details/85005466 将timestamp转为LocalDateTime ```java public LocalDateTime timestamToDatetime(long timestamp){ Instant instant = Instant.ofEpochMilli(timestamp); return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); } ``` 将LocalDataTime转为timestamp ```java public long datatimeToTimestamp(LocalDateTime ldt){ long timestamp = ldt.toInstant(OffsetDateTime.now().getOffset()).toEpochMilli(); return timestamp; } ``` 我在网上还找到了另一个将datetime转为时间戳的方法: ```java ZoneId zone = ZoneId.systemDefault(); long timestamp = ldt.atZone(zone).toInstant().toEpochMilli(); ``` Java8的时间转为时间戳的大概的思路就是LocalDateTime先转为Instant,设置时区,然后转timestamp。 ##### 使用LocalDateTime进行过期计算 ![1553595928703](md-README-imgs/1553595928703.png) ```java public static void main(String[] args) { System.out.println(LocalDateTime.now().plusSeconds(100).isBefore(LocalDateTime.now())); // false LocalDateTime temp = LocalDateTime.now(); System.out.println(temp.isBefore(temp)); // false LocalDateTime plusOne = temp.plusSeconds(1); System.out.println(temp.isBefore(plusOne)); // true System.out.println(LocalDateTime.now().minusSeconds(100).isBefore(LocalDateTime.now())); // true System.out.println("########################################"); System.out.println(LocalDateTime.now().plusSeconds(100).isAfter(LocalDateTime.now())); // true LocalDateTime temp2 = LocalDateTime.now(); System.out.println(temp2.isAfter(temp2)); // false LocalDateTime plusOne2 = temp2.plusSeconds(1); System.out.println(temp2.isAfter(plusOne2)); // false System.out.println(LocalDateTime.now().minusSeconds(100).isAfter(LocalDateTime.now())); // false /** * 判断总结: * .isXXX之前的对象是主参照物,isXXX(参数)的参数是被比对物,XXX是如何比对。 * 之前/之后:未来是前,过去是后。 * 所以比对口诀是:参数对象在主参照物的XXX(之前/之后)??? */ } ``` ##### ServletRequestUtis的使用 - https://blog.csdn.net/neweastsun/article/details/80873994 ##### AntPathMaterch的使用 - https://www.jianshu.com/p/34f87d3097f5 ##### SessionStrategy(用三个操作方法)的使用 - https://www.jianshu.com/p/ecc39ec62340 ##### ServletWebRequest的使用 用于适配request,response对象,目的在于一个参数能够获取两个值,一个是request,另一个是response。 ##### StringUtils的使用 这个工具类要学会用,有字符串判断(看源代码就知道区别),字符串切割(看源代码注释就知道意思) 该过滤器继承了OncePerRequestFilter抽象类,让该过滤器在一次请求中只会运行一次,实现了InitializingBean接口,让Spring管理该过滤器bean时,bean的属性初始化完毕后调用该接口的afterPropertiesSet方法。 这里用到了AntPathMatcher用于路径匹配,支持通配符。 ```java public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean { private SecurityProperties securityProperties; private AuthenticationFailureHandler authenticationFailureHandler; private Set urls = new HashSet<>(); private AntPathMatcher antPathMatcher = new AntPathMatcher(); private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); @Override public void afterPropertiesSet() throws ServletException { super.afterPropertiesSet(); System.out.println("@@@@@@@@@@ 验证码过滤器初始化 @@@@@@@@@@"); String[] configUrls = StringUtils.splitByWholeSeparator(securityProperties.getValidateCode().getValidateUrls(), ","); for (String url : configUrls){ urls.add(url.trim()); } urls.add(securityProperties.getBrowser().getLoginProcessingUrl()); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { boolean perform = false; if(StringUtils.equals(request.getMethod(), "POST")){ for (String url : urls){ if(antPathMatcher.match(url, request.getRequestURI())){ perform = true; break; } } } if(perform){ try { validate(new ServletWebRequest(request)); }catch (ValidateCodeException e){ // 使用自己写的那个认证失败处理器 authenticationFailureHandler.onAuthenticationFailure(request, response, e); // 认证处理器处理后就不向下面走了 return; } } filterChain.doFilter(request, response); } private void validate(ServletWebRequest request) throws ServletRequestBindingException { ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_VALIDATE_CODE); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "validateCode"); if(StringUtils.isBlank(codeInRequest)){ throw new ValidateCodeException("验证码的值不能为空"); } if(codeInSession == null){ throw new ValidateCodeException("验证码不存在"); } if(codeInSession.isExpired()){ sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_VALIDATE_CODE); throw new ValidateCodeException("验证码已过期"); } if(!StringUtils.equalsIgnoreCase(codeInSession.getCode(), codeInRequest)){ throw new ValidateCodeException("验证码不匹配"); } sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_VALIDATE_CODE); } public SecurityProperties getSecurityProperties() { return securityProperties; } public void setSecurityProperties(SecurityProperties securityProperties) { this.securityProperties = securityProperties; } public AuthenticationFailureHandler getAuthenticationFailureHandler() { return authenticationFailureHandler; } public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { this.authenticationFailureHandler = authenticationFailureHandler; } } ``` 因为验证码的验证生成类可能会被替换掉我们在ValidateCodeConfig.class中这样写: ![1553518884417](md-README-imgs/1553518884417.png) 当项目里面有ValidateCodeGenerator接口的实现类的时候,项目就不用我们的默认验证码生成类,通过@ConditionalOnMissingBean(ValidateCodeGenerator.class)注解实现该功能: ```java @Configuration public class ValidateCodeConfig { @Autowired private SecurityProperties securityProperties; @Bean @ConditionalOnMissingBean(ValidateCodeGenerator.class) public ValidateCodeGenerator validateCodeGenerator(){ ImageCodeGenerator imageCodeGenerator = new ImageCodeGenerator(); imageCodeGenerator.setSecurityProperties(securityProperties); return imageCodeGenerator; } } ``` 在browser模块的BrowserSecurityConfig里面写上如下代码: 配置验证码过滤器: ![1553519012793](md-README-imgs/1553519012793.png) 把过滤器的执行放到用户名密码验证之前,并且把验证码图片请求链接给放行认证: 可以直接把过滤器加入到SpringSecurity链中,http.addFilter(自定义过滤器),但是这里有更深入的需求,那就是验证码的验证是在用户名密码验证之前。 ![1553519038302](md-README-imgs/1553519038302.png) ```java @Configuration public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private SecurityProperties securityProperties; @Autowired private BrowserAuthenticationSuccessHandler browserAuthenticationSuccessHandler; @Autowired private BrowserAuthenticationFailureHandler browserAuthenticationFailureHandler; @Bean public ValidateCodeFilter validateCodeFilter(){ ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter(); validateCodeFilter.setAuthenticationFailureHandler(browserAuthenticationFailureHandler); validateCodeFilter.setSecurityProperties(securityProperties); return validateCodeFilter; } @Override protected void configure(HttpSecurity http) throws Exception { // 下面的含义依次为(除and): // 需要表单登录、验证请求,验证规则为验证所有请求、用户验证成功后所有链接都允许访问 http .addFilterBefore(validateCodeFilter(), UsernamePasswordAuthenticationFilter.class) .formLogin() .loginPage("/security/require") .loginProcessingUrl(securityProperties.getBrowser().getLoginProcessingUrl()) .successHandler(browserAuthenticationSuccessHandler) .failureHandler(browserAuthenticationFailureHandler) .and() .authorizeRequests() .antMatchers("/security/require", securityProperties.getBrowser().getLoginPage(), "/security/failure", "/validate/image") .permitAll() .anyRequest() .authenticated() .and() .csrf() .disable(); } @Bean public PasswordEncoder passwordEncoder(){ // 这里的BCryptPasswordEncoder还有很多参数没设置,比如BCrypt加密的轮数,默认是10轮。 return new BCryptPasswordEncoder(); } } ``` 上面验证码功能实现的所有代码,实现了功能应用级别配置,验证码图片大小,实现了请求级别-应用级别-默认配置。 url验证拦截链接也可手动配置。 ## 实现“记住我”功能 ### 记住我功能基本原理 用户认证成功后,RememberMeService会生成一个Token和主键series同用户名和这次登陆时间通过PersistentTokenRepository保存到数据库中,并且把Token和主键series设置到浏览器Cookie中,等退出浏览器,Session过期后,下次登陆时,会通过过滤器RememberMeAuthenticationFilter,该过滤器会把Cookie中的Token和series信息交给RememberMeService,RememberMeService(有两个实现:PersistentTokenBasedRememberMeServices和TokenBasedRememberMeServices)会调用PersistentTokenRepository,PersistentTokenRepository通过主键series在数据库里面查,把查询结果返回给RememberMeService,RememberMeService会判断,如果查到的Token和Cookie里面的Token一致,并且Token还没过期,那么RememberMeService就会调用autoLoginProcessing方法,通过数据库查到用户名,用来作为UserDetailsService的参数,来获取用户的详细信息,获取到后把用户详细信息设置到SecurityContextHolder里面,这就自动登录成功了,自动登录成功后,RememberMeService会根据具体实现(PersistentTokenBasedRememberMeServices或TokenBasedRememberMeServices),再生成新的Token通过主键series去修改数据库的Token,并且修改登录时间,如果有人拿上次旧的Token(remember-me包含Token和series)去登录,那么服务器就会报Cookie被盗取的异常,并且删除数据库所有当前用户的Token信息。 上面说的自动登录后,创建新的Token,前提是JSESSIONID从客户端中没有上传到服务器,也就是打开新的浏览器,只上传了remember-me的Cookie,并且该Token和数据库的Token一致才会更新原来的series对应的Token。 > 但是如果盗用你cookie的人一直保留你的JSESSIONID,那么就算你服务器得Token被删除干净,你也重新登录创建了新的Token,盗用你cookie的人还是能够登录,就是通过你的JSESSIONID。所以登录一个系统后,退出登录是一个非常重要切保证安全的步骤!!!!! ![1553520650868](md-README-imgs/1553520650868.png) RememberMeAuthenticationFilter的执行时机是前面的过滤器没有获取到用户信息时,RememberMeAuthenticationFilter才会执行。 ![1553520773086](md-README-imgs/1553520773086.png) 另外: 拿到用户的Cookie,用Base64查看你就会更理解上面说的原理: ![1553520842150](md-README-imgs/1553520842150.png) 通过Base64解密后: ![1553520878388](md-README-imgs/1553520878388.png) 你会发现浏览器的remember-me的Cookie解密后竟然和数据库的series/Token一模一样,这样也不难揣测出其中的原理了。 ### 记住我功能具体实现 在browser模块,config类里面: ![1553520908073](md-README-imgs/1553520908073.png) 配置PersistentTokenRepository: ```java @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository(){ JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); // jdbcTokenRepository.setCreateTableOnStartup(true); return jdbcTokenRepository; } ``` 并且加入自己的UserDetailsService实现类的Bean: ```java @Autowired private UserDetailsService userDetailsService; ``` 在core模块配置记住我多久时间: ![1553521095272](md-README-imgs/1553521095272.png) 然后在浏览器模块的:BrowserSecurityConfig里面配置: 配置了rememberMe功能的PersistentTokenRepository(保存Token到数据库),Token过期时间,自动登录时通过用户名调用UserDetailsService查询用户详细信息的功能。 这三个东西必须都要配,过期时间当然有默认值,其它两个必须配。 ![1553521152248](md-README-imgs/1553521152248.png) 在登录页面加入“记住我”的功能: ![1553521165455](md-README-imgs/1553521165455.png) 另外还可以配置: ![1553521207500](md-README-imgs/1553521207500.png) 记住我参数提交的name,默认是"remember-me",可以通过参数rememberMeParameter配置。 rememberMeServices能够配置默认的记住我服务,默认是PersistentTokenBasedRememberMeServices,还可以配置为:TokenBasedRememberMeServices。 参考链接: - https://www.oschina.net/question/914161_139109?sort=time - https://www.cnblogs.com/haore147/p/5213510.html - https://www.cnblogs.com/hongxf1990/p/6605683.html - 处理SpringSecurity异常:https://blog.csdn.net/XlxfyzsFdblj/article/details/82290500(Cookie盗用异常处理) #### 扩展:是谁动了我的Cookie?Spring Security自动登录功能开发经历总结 在使用Spring Security的remember me模块为云端开源论坛XLineCode开发自动登录模块时,本着用户至上的理念优先采用了PersistentTokenBasedRememberMeServices作为实现根基。通过参考Spring Security的官网推荐书籍Spring Security 3 ,很简单也很顺利的在Spring Security命名空间下配置了该service,本地环境也简单测试通过了该功能,但在上线后接近两个月的时间内,却出现了各种多线程引发的问题而导致自动登录功能失效的情况。 **Spring Security的remember me模块的两个实现机制简介**: **PersistentTokenBasedRememberMeServices**: 使用JdbcTokenRepositoryImpl在数据库创建persistent_logins表。当勾选自动登录时随机产生键值对并将该键值对保存在persistent_logins表及浏览器的cookie中,在浏览器当前会话失效后从下一request中提取cookie键值对,通过对比是否与数据库中的键值对一致来判断该自动功能是否有效。有效则根据该key生成新的token并更新至数据库和浏览器。如根据key未能从数据库找到记录,则不进行登录授权并取消该key在浏览器中对应的cookie。如该key对应的cookie token与数据库得到的token不一致,则该cookie在浏览器中可能已经被黑客攻破,被盗用于信息窃取,因此抛出cookie被盗的异常并根据该用户id删除其在数据库对应的所有键值记录(该用户在多个浏览器登录则有多条记录)。 *核心方法processAutoLoginCookie代码如下*: ***TokenBasedRememberMeServices***: 根据用户id和密码生成cookie,同样通过对比判断cookie是否有效。该机制的缺点是用户修改密码后其在其他浏览器中的cookie不会自动更新,需再次登陆生成cookie。另一缺点是密码经过加密后保存在客户端,存在一定风险。 *核心方法processAutoLoginCookie代码如下*: ***使用PersistentTokenBasedRememberMeServices遇到的各种情况***: \1. 因Spring Security的filter链未作并发控制,所以PersistentTokenBasedRememberMeServices的processAutoLoginCookie在处理自动登录时如服务器负载过大用户多次刷新页面会产生线程A和B同时在运行processAutoLoginCookie的情况。假设A是先于B的请求,而A在未执行*tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());*时被系统挂起了线程,导致B先于A完成了请求。此时A获得资源后执行完时因A请求已被浏览器取消,故A所产生的新的cookie无法在浏览器中更新,导致该浏览器的下次请求中的cookie与数据库中的token不一致从而抛出Cookie被盗的异常(该情况只在极低的概率下才可能发生)。 \2. 跑完PersistentTokenBasedRememberMeServices的processAutoLoginCookie后在业务逻辑中如抛出异常而未被代码或者web.xml中的500配置捕获,则会直接返回500错误给浏览器,此时新生成的cookie不会在浏览器更新,同样会导致cookie被盗的情况。不过一般情况下都会捕获,问题不大。 \3. filter配置不当导致拦截了页面加载后触发的ajax或一般业务请求:同上,在发生异常时导致cookie更新失败。 \4. 在浏览器请求未返回时直接取消该请求,因该请求在服务器执行完后不能正常设置浏览器的cookie,导致浏览器下次请求再次使用失效的cookie时processAutoLoginCookie发现两边token不一致,抛出cookie被盗异常。这种情况在网络不理想的情况下被用户触发的可能性最大,而Spring Security代码中也没有处理该情况的代码。仅仅因为用户取消请求而抛出cookie被盗的异常并清空该用户在其他浏览器的cookie会造成相当差的用户体验,这也是我决定转为采用TokenBasedRememberMeServices的原因. 使用TokenBasedRememberMeServices则相对简单很多,无需考虑并发的情况,只要cookie匹配就可自动登录。 后话:我在发现该情况后分别尝试了oschina,csdn和iteye的登录机制,发现他们均使用的是TokenBasedRememberMeServices的方式。 代码部分结束。 ### 记住我功能SpringSecurity源码解析 ## 实现短信验证码登录 ### 开发短信验证码生成接口 写短信验证码的dto类: 只包含验证码和过期时间 ![1553768766788](md-README-imgs/1553768766788.png) 因为和图形验证码有代码重复,让图形验证码继承短信验证码,并且重写构造方法: ![1553768839275](md-README-imgs/1553768839275.png) 构建短信验证码配置类: 短信验证码配置类只包含,验证码的长度、随机源、过期时间、拦截的URLS: ![1553768860498](md-README-imgs/1553768860498.png) 因为有重复的代码,所以让,图形验证码配置类继承短信验证码配置类,并且重写构造方法,因为图形验证码也有默认配置。 ![1553820929017](md-README-imgs/1553820929017.png) 在验证配置类里面配置上除图形验证码配置的短信验证码: ![1553821069019](md-README-imgs/1553821069019.png) 在图像验证码包里面增加短信验证码生成器: ![1553821093408](md-README-imgs/1553821093408.png) ```java public class SmsCodeGenerator implements ValidateCodeGenerator { private SecurityProperties securityProperties; public void setSecurityProperties(SecurityProperties securityProperties) { this.securityProperties = securityProperties; } @Override public ValidateCode generate(HttpServletRequest request) { String code = RandomStringUtils.random(securityProperties.getValidateCode().getSms().getLength(), securityProperties.getValidateCode().getSms().getOriginCode()); return new ValidateCode(code, securityProperties.getValidateCode().getSms().getExpireIn()); } } ``` 在core模块里面增加sms包,里面写短信发送提供商: ![1553821147642](md-README-imgs/1553821147642.png) 声明一个接口: ![1553821167752](md-README-imgs/1553821167752.png) 实现一个默认的短信发送提供商(直接输出控制台): ![1553821190961](md-README-imgs/1553821190961.png) 在ValidateCodeController里面增加获取短信验证码接口: ![1553821479549](md-README-imgs/1553821479549.png) 用到的成员实例: ![1553821492533](md-README-imgs/1553821492533.png) 增加一个把生成后的验证码保存到Session中的key: 一个用于图形验证码,另一个用于短信验证码: ![1553821508105](md-README-imgs/1553821508105.png) 在登录页面增加如下代码: ![1553821596918](md-README-imgs/1553821596918.png) 配置验证码获取接口放行: /validate/image和/validate/sms ![1553821638356](md-README-imgs/1553821638356.png) 运行后效果: ![1553821652069](md-README-imgs/1553821652069.png) ### 重构短信验证码生成接口 重构上述代码: 因为Controller中有相同的步骤,重复得代码: 生成==>保存到session==>发送, 所以用模板方法重构: ![1553931277868](md-README-imgs/1553931277868.png) 代码结构: ![1553931326812](md-README-imgs/1553931326812.png) 重构后的代码结构: ![1553931344157](md-README-imgs/1553931344157.png) 或者这样 ![1553931383730](md-README-imgs/1553931383730.png) 声明一个接口,用户创建验证码: ![1553931400385](md-README-imgs/1553931400385.png) 创建一个抽象类去实现它: ```java public abstract class AbstractValidateCodeProcessor implements ValidateCodeProcessor { /** * 操作session的工具类 */ private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); /** * 收集Spring容器中所有的 {@link ValidateCodeGenerator}接口实现 */ @Autowired private Map validateCodeGenerators; @Override public void create(ServletWebRequest request, String validateCodeSessionKey) throws Exception { C validateCode = generate(request); save(request, validateCode, validateCodeSessionKey); send(request, validateCode); } private void save(ServletWebRequest request, C validateCode, String validateCodeSessionKey) { sessionStrategy.setAttribute(request, validateCodeSessionKey, validateCode); } private C generate(ServletWebRequest request) { return (C) validateCodeGenerators.get(getProcessorType(request)).generate(request.getRequest()); } protected abstract void send(ServletWebRequest request, C validateCode) throws Exception; private String getProcessorType(ServletWebRequest request){ return StringUtils.substringAfter(request.getRequest().getRequestURI(), "/validate/")+"CodeGenerator"; } } ``` 创建两个处理器: 图片处理器: ```java @Component public class ImageCodeProcessor extends AbstractValidateCodeProcessor { @Override protected void send(ServletWebRequest request, ImageCode validateCode) throws IOException { ImageIO.write(validateCode.getImage(), "JPEG", request.getResponse().getOutputStream()); validateCode.setImage(null); // 释放图片内存 } } ``` sms处理器: ```java @Component public class SmsCodeProcessor extends AbstractValidateCodeProcessor { @Autowired private SmsCodeSender smsCodeSender; @Override protected void send(ServletWebRequest request, ValidateCode validateCode) throws Exception { String mobile = ServletRequestUtils.getRequiredStringParameter(request.getRequest(), "mobile"); smsCodeSender.send(mobile, validateCode.getCode()); } } ``` 验证码控制类这样写: ```java @RestController @RequestMapping("/validate") public class ValidateCodeController { /** * 短信/图形 验证码放入Session的Key */ public final static String SESSION_VALIDATE_CODE = "SESSION_VALIDATE_CODE"; /** * 图形验证码的SessionKey */ public final static String SESSION_VALIDATE_CODE_IMAGE = SESSION_VALIDATE_CODE+"_IMAGE"; @Autowired private Map validateCodeProcessors; @GetMapping("/{type}") public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws Exception { validateCodeProcessors.get(type+"CodeProcessor").create(new ServletWebRequest(request, response), SESSION_VALIDATE_CODE+"_"+type.toUpperCase()); } } ``` 然后就重构完成了, 相比以前,我只需要增加实现代码,就能够自动注入并且实现功能了。 ### 校验短信验证码并登录 ![1553932506260](md-README-imgs/1553932506260.png) 用户名密码登录的原理:用户输入用户名密码,并且点击登录后,UsernamePasswordAuthenticationFilter处理登录请求,在里面获取前台传来的用户名和密码封装成一个未认证的UsernamePasswordAuthenticationToken(UsernamePasswordAuthenticationToken是Authentication的子类),然后把未认证的UsernamePasswordAuthenticationToken对象传给AuthenticationManager,AuthenticationManager在众多的XXXAuthenticationProvider中找到一个能够处理UsernamePasswordAuthenticationToken对象的Provider,然后XXXAuthenticationProvider通过用户名调用UserDetailService,获取该用户的密码和权限信息,如果UserDetailService获取到为null,那么就抛出未找到用户异常,如果找到了就把UserDetails放到UsernamePasswordAuthenticationToken的principle属性里面,并且给UsernamePasswordAuthenticationToken设置用户权限,同时把IP等登陆信息通过setDetails方法设置到UsernamePasswordAuthenticationToken的最终对象里面。这个最终UsernamePasswordAuthenticationToken对象就是已认证的Authentication。然后在AuthenticationManager里面,把最终的UsernamePasswordAuthenticationToken对象放入SecurityContextHandler里面,这样就登录成功了。 短信验证码登录也是这样一个方式。 只不过,短信验证码的验证不在这个逻辑里面,不会像验证用户名密码那样把短信验证码验证写进去,短信验证码的验证将会抽离出来像图形验证码那样验证,这样就保证了短信验证码能够配置到各种链接上面去,但是这也导致了,短信验证码登录接口,如果没有配置短信验证码过滤,那么通过该接口能够直接通过POST方式登录,而且是能够传入各种手机号没有任何验证就能登录。 代码结构为 ![1553947086293](md-README-imgs/1553947086293.png) #### 短信验证码登录流程的代码 根据上面的图,创建SmsCodeAuthenticationToken,就是继承UsernamePasswordAuthenticationToken的父类 ```java public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; public SmsCodeAuthenticationToken(Object principal) { super((Collection)null); this.principal = principal; this.setAuthenticated(false); } public SmsCodeAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); } public Object getCredentials() { return null; } public Object getPrincipal() { return this.principal; } public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } else { super.setAuthenticated(false); } } public void eraseCredentials() { super.eraseCredentials(); } } ``` 创建异常类MobileEmptyAuthenticationException ```java public class MobileEmptyAuthenticationException extends AuthenticationException { public MobileEmptyAuthenticationException(String msg) { super(msg); } } ``` 创建SmsCodeAuthenticationFilter,同样也是模仿用户名密码登录,继承UsernamePasswordAuthenticaitonFilter的父类,短信验证码的登录接口为:/authentication/sms 请求中,手机号的参数名为mobile ```java public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_SMS_KEY = "mobile"; private String mobileParameter = SPRING_SECURITY_SMS_KEY; private boolean postOnly = true; public SmsCodeAuthenticationFilter() { super(new AntPathRequestMatcher("/authentication/sms", "POST")); } public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String mobile = this.obtainMobile(request); if (StringUtils.isBlank(mobile)) { throw new MobileEmptyAuthenticationException("手机号不能为空!"); } mobile = mobile.trim(); SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } } protected String obtainMobile(HttpServletRequest request) { return request.getParameter(this.mobileParameter); } protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } public void setMobileParameter(String mobileParameter) { Assert.hasText(mobileParameter, "Username parameter must not be empty or null"); this.mobileParameter = mobileParameter; } public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } public final String getMobileParameter() { return this.mobileParameter; } } ``` 创建SmsCodeAuthenticationProvider,同上模拟DaoAuthenticationProvider ```java public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String mobile = (String) authentication.getPrincipal(); UserDetails userDetails = userDetailService.loadUserByUsername(mobile); if(userDetails == null){ throw new UsernameNotFoundException(String.format("手机号为【%s】的用户还未注册!", mobile)); } SmsCodeAuthenticationToken smsAuthenticationTokenResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities()); smsAuthenticationTokenResult.setDetails(authentication.getDetails()); return smsAuthenticationTokenResult; } @Override public boolean supports(Class aClass) { return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass); } public void setUserDetailService(UserDetailsService userDetailService) { this.userDetailService = userDetailService; } } ``` #### 短信验证码验证过滤器 创建SmsCodeFilter ```java public class SmsCodeFilter extends OncePerRequestFilter implements InitializingBean { private AuthenticationFailureHandler authenticationFailureHandler; private SecurityProperties securityProperties; private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); private Set filterUrls = new HashSet<>(); @Override public void afterPropertiesSet() throws ServletException { String[] splitFilterUrls = StringUtils.splitByWholeSeparator(securityProperties.getValidateCode() .getSms().getFilterUrls(), ","); for(String url : splitFilterUrls){ filterUrls.add(url); } filterUrls.add("/authentication/sms"); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { boolean isPerform = false; for(String filterUrl : filterUrls) { if(StringUtils.equals(request.getRequestURI(), filterUrl)){ isPerform = true; break; } } if(isPerform){ try{ validate(new ServletWebRequest(request, response)); }catch (ValidateCodeException ve){ // 使用自己写的那个认证失败处理器 authenticationFailureHandler.onAuthenticationFailure(request, response, ve); // 认证处理器处理后就不向下面走了 return; } } filterChain.doFilter(request, response); } private void validate(ServletWebRequest request) throws ServletRequestBindingException { ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_SMS_VALIDATE_CODE); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode"); if(codeInSession == null){ throw new ValidateCodeException("验证码不存在"); } if(StringUtils.isBlank(codeInRequest)){ throw new ValidateCodeException("验证码的值不能为空"); } if(codeInSession.isExpired()){ sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_IMAGE_VALIDATE_CODE); throw new ValidateCodeException("验证码已过期"); } if(!StringUtils.equalsIgnoreCase(codeInSession.getCode(), codeInRequest)){ throw new ValidateCodeException("验证码不匹配"); } sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_SMS_VALIDATE_CODE); } public SecurityProperties getSecurityProperties() { return securityProperties; } public void setSecurityProperties(SecurityProperties securityProperties) { this.securityProperties = securityProperties; } public AuthenticationFailureHandler getAuthenticationFailureHandler() { return authenticationFailureHandler; } public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { this.authenticationFailureHandler = authenticationFailureHandler; } } ``` #### 通过配置让短信验证码登录接口生效,并且让短信验证码验证过滤器生效 创建SmsCodeAuthenticationSecurityConfig ```java @Component public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private AuthenticationFailureHandler authenticationFailureHandler; @Autowired private UserDetailsService myUserDetailService; @Override public void configure(HttpSecurity http) throws Exception { SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter(); smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler); smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler); SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider(); smsCodeAuthenticationProvider.setUserDetailService(myUserDetailService); // 这个方法就会把smsCodeAuthenticationProvider加到AuthenticationManager的集合里面去 http .authenticationProvider(smsCodeAuthenticationProvider) // 把smsCodeAuthenticationFilter过滤器放到用户名密码过滤器之后,这里很重要,和短信验证码的验证过滤器形成对比, // 一前一后 .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } } ``` 在这里面加入 ![1553947266667](md-README-imgs/1553947266667.png) ![1553947281201](md-README-imgs/1553947281201.png) 并且引入 ![1553947467568](md-README-imgs/1553947467568.png) 应用配置 ![1553947481258](md-README-imgs/1553947481258.png) 把短信验证码验证过滤器加到用户名密码验证之前,而短信登录接口在用户名密码登录过滤器之后,这样配置短信验证码验证过滤器去验证短信验证码登录接口才有用 ![1553947502502](md-README-imgs/1553947502502.png) 短信验证码登录接口不用放行,自动放行(/authentication/sms) ![1553947636849](md-README-imgs/1553947636849.png) 如果让短信验证码验证过滤器不去验证短信登录接口 ![1553947816342](md-README-imgs/1553947816342.png) 那么直接POST到/authentication/sms后,就登录成功 ![](md-README-imgs/录制_2019_03_30_20_12_03_53.gif) 访问/public后,能够看到session里面有了登录信息 ![1553948109295](md-README-imgs/1553948109295.png) ![1553948126511](md-README-imgs/1553948126511.png) #### 重构验证码登录 ![1554100098486](md-README-imgs/1554100098486.png) 目录结构 ![1554133535614](md-README-imgs/1554133535614.png) ![1554133559064](md-README-imgs/1554133559064.png) 把所有的静态字符串统一到SecurityConstants里面 ```java public interface SecurityConstants { /** * 默认的处理验证码的url前缀 */ String DEFAULT_VALIDATE_CODE_URL_PREFIX = "/validate"; /** * 当请求需要身份认证时,默认跳转的url */ String DEFAULT_UNAUTHENTICATION_URL = "/authentication/require"; /** * 当请求身份认证成功跳转的url */ String DEFAULT_AUTHENTICATION_SUCCESS_URL = "/authentication/success"; /** * 当请求身份认证失败跳转的url */ String DEFAULT_AUTHENTICATION_FAILURE_URL = "/authentication/failure"; /** * 默认的用户名密码登录请求处理url */ String DEFAULT_LOGIN_PROCESSING_URL_FORM = "/authentication/form"; /** * 默认的手机验证码登录请求处理url */ String DEFAULT_LOGIN_PROCESSING_URL_MOBILE = "/authentication/sms"; /** * 默认登录页面 */ String DEFAULT_LOGIN_PAGE_URL = "/login.html"; /** * 验证图片验证码时,http请求中默认的携带图片验证码信息的参数的名称 */ String DEFAULT_PARAMETER_NAME_CODE_IMAGE = "imageCode"; /** * 验证短信验证码时,http请求中默认的携带短信验证码信息的参数的名称 */ String DEFAULT_PARAMETER_NAME_CODE_SMS = "smsCode"; /** * 发送短信验证码 或 验证短信验证码时,传递手机号的参数的名称 */ String DEFAULT_PARAMETER_NAME_MOBILE = "mobile"; } ``` 创建验证码类型枚举 ```java public enum ValidateCodeType { /** * 短信验证码 */ SMS { @Override public String getParamNameOnValidate() { return SecurityConstants.DEFAULT_PARAMETER_NAME_CODE_SMS; } }, /** * 图形验证码 */ IMAGE { @Override public String getParamNameOnValidate() { return SecurityConstants.DEFAULT_PARAMETER_NAME_CODE_IMAGE; } }; /** * 校验时从请求中获取的参数的名字 * @return */ public abstract String getParamNameOnValidate(); } ``` 创建验证码处理器Holder ```java @Component public class ValidateCodeProcessorHolder { @Autowired private Map validateCodeProcessors; public ValidateCodeProcessor findValidateCodeProcessor(ValidateCodeType type){ return findValidateCodeProcessor(type.toString().toLowerCase().trim()); } public ValidateCodeProcessor findValidateCodeProcessor(String type){ String name = type.toLowerCase() + ValidateCodeProcessor.class.getSimpleName(); ValidateCodeProcessor processor = validateCodeProcessors.get(name); if(processor == null){ throw new ValidateCodeException(String.format("验证码处理器【%s】不存在!", name)); } return processor; } } ``` 验证码处理器接口 ```java public interface ValidateCodeProcessor { String VALIDATE_CODE_SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_"; /** * 创建校验码 * @param request */ void create(ServletWebRequest request) throws IOException, ServletRequestBindingException; void validate(ServletWebRequest request) throws ServletRequestBindingException; } ``` 重构验证码处理器抽象实现类 ```java public abstract class AbstractValidateCodeProcessor implements ValidateCodeProcessor { /** * 操作Session工具类 */ private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); @Autowired private Map validateCodeGenerators; @Override public void create(ServletWebRequest request) throws IOException, ServletRequestBindingException { T validateCode = generate(request); save(validateCode, request); send(request, validateCode); } protected abstract void send(ServletWebRequest request, T validateCode) throws IOException, ServletRequestBindingException; private void save(T validateCode, ServletWebRequest request) { sessionStrategy.setAttribute(request, getSessionKey(), validateCode); } public String getSessionKey(){ return VALIDATE_CODE_SESSION_KEY_PREFIX+getValidateCodeType().toString().toUpperCase(); } private ValidateCodeType getValidateCodeType(){ String type = StringUtils.substringBefore(getClass().getSimpleName(), "ValidateCodeProcessor").toUpperCase(); return ValidateCodeType.valueOf(type); } private T generate(ServletWebRequest request){ return (T) validateCodeGenerators.get(getProcessType(request)).generate(request.getRequest()); } private String getProcessType(ServletWebRequest request) { return StringUtils.substringAfter(request.getRequest().getRequestURI(), "/validate/")+"CodeGenerator"; } @Override public void validate(ServletWebRequest request) throws ServletRequestBindingException { ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request, getSessionKey()); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), getValidateCodeType().getParamNameOnValidate()); if(codeInSession == null){ throw new ValidateCodeException("验证码不存在"); } if(StringUtils.isBlank(codeInRequest)){ throw new ValidateCodeException("验证码的值不能为空"); } if(codeInSession.isExpired()){ sessionStrategy.removeAttribute(request, getSessionKey()); throw new ValidateCodeException("验证码已过期"); } if(!StringUtils.equalsIgnoreCase(codeInSession.getCode(), codeInRequest)){ throw new ValidateCodeException("验证码不匹配"); } sessionStrategy.removeAttribute(request, getSessionKey()); } } ``` 验证码获取Controller ```java @RestController @RequestMapping(SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX) public class ValidateCodeController { @Autowired private ValidateCodeProcessorHolder validateCodeProcessorHolder; @GetMapping("/{type}") public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type) throws IOException, ServletRequestBindingException { validateCodeProcessorHolder.findValidateCodeProcessor(type).create(new ServletWebRequest(request, response)); } } ``` 到这里验证码的处理类,也就是验证码获取功能(图片和sms),就重构完毕,接下来重构验证码校验过滤器,把图片验证码校验器和短信验证码校验器合并。 ```java @Component public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean { @Autowired private AuthenticationFailureHandler authenticationFailureHandler; @Autowired private SecurityProperties securityProperties; @Autowired private ValidateCodeProcessorHolder validateCodeProcessorHolder; /** * 所有需要校验验证码的URL */ private Map filterUrlMap = new HashMap<>(); /** * 验证请求URL与配置的URL是否匹配的工具类 */ private AntPathMatcher antPathMatcher = new AntPathMatcher(); @Override public void afterPropertiesSet() throws ServletException { if(securityProperties.getValidateCode().getImage().isValidateLoginProcess()){ filterUrlMap.put(securityProperties.getBrowser().getLoginProcessingUrl(), ValidateCodeType.IMAGE); } addUrlToFilter(securityProperties.getValidateCode().getImage().getFilterUrls(), ValidateCodeType.IMAGE); filterUrlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, ValidateCodeType.SMS); addUrlToFilter(securityProperties.getValidateCode().getSms().getFilterUrls(), ValidateCodeType.SMS); } private void addUrlToFilter(String filterUrls, ValidateCodeType type) { String[] urls = StringUtils.splitByWholeSeparator(filterUrls, ","); for(String url : urls){ filterUrlMap.put(url.trim(), type); } } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ValidateCodeType type = getUrlValidateType(request.getRequestURI()); if(type != null){ try{ validateCodeProcessorHolder.findValidateCodeProcessor(type) .validate(new ServletWebRequest(request, response)); }catch (ValidateCodeException ve){ // 使用自己写的那个认证失败处理器 authenticationFailureHandler.onAuthenticationFailure(request, response, ve); // 认证处理器处理后就不向下面走了 return; } } filterChain.doFilter(request, response); } private ValidateCodeType getUrlValidateType(String url){ Set keys = filterUrlMap.keySet(); for(String key : keys){ if(antPathMatcher.match(key, url)){ return filterUrlMap.get(key); } } return null; } } ``` 重构配置类 把验证码校验过滤器单独配置 ```java @Configuration public class ValidateCodeSecurityConfig extends SecurityConfigurerAdapter { @Autowired private ValidateCodeFilter validateCodeFilter; @Override public void configure(HttpSecurity http) throws Exception { http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class); } } ``` 把登录的表单配置抽离出来 ```java public class AbstractChannelSecurityConfig extends WebSecurityConfigurerAdapter{ @Autowired private SecurityProperties securityProperties; @Autowired protected AuthenticationSuccessHandler authenticationSuccessHandler; @Autowired protected AuthenticationFailureHandler authenticationFailureHandler; protected void applyPasswordAuthenticationConfig(HttpSecurity http) throws Exception { http.formLogin() .loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL) .loginProcessingUrl(securityProperties.getBrowser().getLoginProcessingUrl()) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler); } } ``` 然后让浏览器config继承AbstractChannelSecurityConfig ```java BrowserSecurityConfig extends AbstractChannelSecurityConfig { ``` 应用配置信息到浏览器config ![1554135238192](md-README-imgs/1554135238192.png) ```java @Configuration public class BrowserSecurityConfig extends AbstractChannelSecurityConfig { @Autowired private SecurityProperties securityProperties; @Autowired private DataSource dataSource; @Autowired private UserDetailsService myUserDetailService; @Autowired private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig; @Autowired private ValidateCodeSecurityConfig validateCodeSecurityConfig; @Bean public PersistentTokenRepository persistentTokenRepository(){ PersistentTokenRepository persistentTokenRepository = new JdbcTokenRepositoryImpl(); ((JdbcTokenRepositoryImpl) persistentTokenRepository).setDataSource(dataSource); return persistentTokenRepository; } @Override protected void configure(HttpSecurity http) throws Exception { applyPasswordAuthenticationConfig(http); /** * 下面的含义依次为(除and): * 需要表单登录、验证请求、验证规则为所有请求、用户验证成功后所有链接都允许访问 */ http .apply(validateCodeSecurityConfig) .and() .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds()) .userDetailsService(myUserDetailService) .and() .authorizeRequests() .antMatchers( "/authentication/require", securityProperties.getBrowser().getLoginPage(), "/authentication/failure", "/public", "/validate/image", "/validate/sms" ) .permitAll() .anyRequest() .authenticated() .and() .csrf() .disable() .apply(smsCodeAuthenticationSecurityConfig); } } ```