系统环境:
windows 10
jdk 1.8
springboot版本: 2.1.10.RELEASE
com.github.penggle kaptcha 2.3.2 org.springframework.boot spring-boot-starter-data-redis org.springframework.boot spring-boot-starter-thymeleaf
server:port: 81
spring:redis:database: 1host: 127.0.0.1port: 6379password: # 密码(默认为空)timeout: 6000ms # 连接超时时长(毫秒)lettuce:pool:max-active: 1000 # 连接池最大连接数(使用负值表示没有限制)max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)max-idle: 10 # 连接池中的最大空闲连接min-idle: 5 # 连接池中的最小空闲连接
验证码文本生成器:这个需要自己生成并且修改下面的配置文件为你文件的路径
package com.yolo.springboot.kaptcha.config;import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import java.util.Properties;/*** @ClassName CaptchaConfig* @Description 验证码配置* @Author hl* @Date 2022/12/6 9:37* @Version 1.0*/
@Configuration
public class CaptchaConfig {@Bean(name = "captchaProducerMath")public DefaultKaptcha getKaptchaBeanMath() {DefaultKaptcha defaultKaptcha = new DefaultKaptcha();Properties properties = new Properties();// 是否有边框 默认为true 我们可以自己设置yes,noproperties.setProperty("kaptcha.border", "yes");// 边框颜色 默认为Color.BLACKproperties.setProperty("kaptcha.border.color", "105,179,90");// 验证码文本字符颜色 默认为Color.BLACKproperties.setProperty("kaptcha.textproducer.font.color", "blue");// 验证码图片宽度 默认为200properties.setProperty("kaptcha.image.width", "160");// 验证码图片高度 默认为50properties.setProperty("kaptcha.image.height", "60");// 验证码文本字符大小 默认为40properties.setProperty("kaptcha.textproducer.font.size", "35");// KAPTCHA_SESSION_KEYproperties.setProperty("kaptcha.session.key", "kaptchaCodeMath");// 验证码文本生成器properties.setProperty("kaptcha.textproducer.impl", "com.yolo.springboot.kaptcha.config.KaptchaTextCreator");// 验证码文本字符间距 默认为2properties.setProperty("kaptcha.textproducer.char.space", "3");// 验证码文本字符长度 默认为5properties.setProperty("kaptcha.textproducer.char.length", "6");// 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1,// fontSize)properties.setProperty("kaptcha.textproducer.font.names", "Arial,Courier");// 验证码噪点颜色 默认为Color.BLACKproperties.setProperty("kaptcha.noise.color", "white");// 干扰实现类properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");// 图片样式 水纹com.google.code.kaptcha.impl.WaterRipple// 鱼眼com.google.code.kaptcha.impl.FishEyeGimpy// 阴影com.google.code.kaptcha.impl.ShadowGimpyproperties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.ShadowGimpy");Config config = new Config(properties);defaultKaptcha.setConfig(config);return defaultKaptcha;}
}
package com.yolo.springboot.kaptcha.config;import com.google.code.kaptcha.text.impl.DefaultTextCreator;import java.util.Random;/*** @ClassName KaptchaTextCreator* @Description 验证码文本生成器* @Author hl* @Date 2022/12/6 10:14* @Version 1.0*/
public class KaptchaTextCreator extends DefaultTextCreator {private static final String[] Number = "0,1,2,3,4,5,6,7,8,9,10".split(",");@Overridepublic String getText(){int result;Random random = new Random();int x = random.nextInt(10);int y = random.nextInt(10);StringBuilder suChinese = new StringBuilder();int randomOperand = (int) Math.round(Math.random() * 2);if (randomOperand == 0) {result = x * y;suChinese.append(Number[x]);suChinese.append("*");suChinese.append(Number[y]);} else if (randomOperand == 1) {if (!(x == 0) && y % x == 0) {result = y / x;suChinese.append(Number[y]);suChinese.append("/");suChinese.append(Number[x]);} else {result = x + y;suChinese.append(Number[x]);suChinese.append("+");suChinese.append(Number[y]);}} else if (randomOperand == 2) {if (x >= y) {result = x - y;suChinese.append(Number[x]);suChinese.append("-");suChinese.append(Number[y]);} else {result = y - x;suChinese.append(Number[y]);suChinese.append("-");suChinese.append(Number[x]);}} else {result = x + y;suChinese.append(Number[x]);suChinese.append("+");suChinese.append(Number[y]);}suChinese.append("=?@").append(result);return suChinese.toString();}
}
package com.yolo.springboot.kaptcha.controller;import cn.hutool.json.JSONUtil;
import com.google.code.kaptcha.Producer;
import com.hl.springbootcommon.common.HttpResponseTemp;
import com.hl.springbootcommon.common.ResultStat;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.util.FastByteArrayOutputStream;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;/*** @ClassName CaptchaController* @Description 验证码* @Author hl* @Date 2022/12/6 9:45* @Version 1.0*/
@RestController
@Slf4j
public class CaptchaController {@Autowiredprivate Producer producer;@Autowiredprivate StringRedisTemplate redisTemplate;public static final String DEFAULT_CODE_KEY = "random_code_";/*** @MethodName createCaptcha* @Description 生成验证码* @param httpServletResponse 响应流* @Author hl* @Date 2022/12/6 10:30*/@GetMapping("/create/captcha")public void createCaptcha(HttpServletResponse httpServletResponse) throws IOException {// 生成验证码String capText = producer.createText();String capStr = capText.substring(0, capText.lastIndexOf("@"));String result = capText.substring(capText.lastIndexOf("@") + 1);BufferedImage image = producer.createImage(capStr);// 保存验证码信息String randomStr = UUID.randomUUID().toString().replaceAll("-", "");System.out.println("随机数为:" + randomStr);redisTemplate.opsForValue().set(DEFAULT_CODE_KEY + randomStr, result, 3600, TimeUnit.SECONDS);// 转换流信息写出FastByteArrayOutputStream os = new FastByteArrayOutputStream();try {ImageIO.write(image, "jpg", os);} catch (IOException e) {log.error("ImageIO write err", e);httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);return;}// 定义response输出类型为image/jpeg类型,使用response输出流输出图片的byte数组byte[] bytes = os.toByteArray();//设置响应头httpServletResponse.setHeader("Cache-Control", "no-store");//设置响应头httpServletResponse.setHeader("randomstr",randomStr);//设置响应头httpServletResponse.setHeader("Pragma", "no-cache");//在代理服务器端防止缓冲httpServletResponse.setDateHeader("Expires", 0);//设置响应内容类型ServletOutputStream responseOutputStream = httpServletResponse.getOutputStream();responseOutputStream.write(bytes);responseOutputStream.flush();responseOutputStream.close();}
}
这里校验验证码,我用了过滤器来实现的,其中遇到了很多问题,下面有我详细的解决方法
@PostMapping("/login")public HttpResponseTemp> login(@RequestBody LoginDto loginDto){System.out.println(JSONUtil.toJsonStr(loginDto));return ResultStat.OK.wrap("","成功");}@Data
public class LoginDto {private String captcha;private String randomStr;
}
这里是我写了一个简单的前端页面,然后发现这里会有一些前端的文件,所以需要过滤一下
package com.yolo.springboot.kaptcha.filter;import cn.hutool.core.collection.ListUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;/*** @ClassName SuffixFilter* @Description 前端文件过滤* @Author hl* @Date 2022/12/6 12:40* @Version 1.0*/
public class FrontFilter extends ShallowEtagHeaderFilter implements Filter {private static final List suffix = ListUtil.of(".css",".eot",".gif",".ico",".js",".map",".png",".svg",".swf",".ttf",".TTF",".woff",".woff2");@Overrideprotected boolean shouldNotFilterAsyncDispatch() {return false;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {response.setHeader("Server", "Apache-Coyote/1.1");response.setHeader("Cache-Control", "max-age=0");String uri = request.getRequestURI();if (!StringUtils.isBlank(uri)) {int index = uri.lastIndexOf(".");if (index > 0 && suffix.contains(uri.substring(index))) {response.setHeader("Cache-Control", "max-age=3600");}if (uri.startsWith("/lib")) {response.setHeader("Cache-Control", "max-age=3600, immutable");}}super.doFilterInternal(request, response, filterChain);}
}
然后需要把我们自定的过滤器加入到spring中让他生效
package com.yolo.springboot.kaptcha.config;import com.yolo.springboot.kaptcha.filter.FrontFilter;
import com.yolo.springboot.kaptcha.filter.ImgCodeFilter;
import com.yolo.springboot.kaptcha.filter.BodyReaderFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;@Configuration
public class FilterConfig {@Beanpublic FilterRegistrationBean> frontFilterRegistration() {FilterRegistrationBean registration = new FilterRegistrationBean<>();// 将过滤器配置到FilterRegistrationBean对象中registration.setFilter(new FrontFilter());// 给过滤器取名registration.setName("frontFilter");// 设置过滤器优先级,该值越小越优先被执行registration.setOrder(0);List urlPatterns = new ArrayList<>();urlPatterns.add("/*");// 设置urlPatterns参数registration.setUrlPatterns(urlPatterns);return registration;}
}
这里我给他设置的拦截全部请求,并且优先级是第一位的
package com.yolo.springboot.kaptcha.filter;import com.alibaba.fastjson.JSONObject;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.stream.Collectors;/*** @ClassName ImgCodeFilter* @Description 验证码处理* @Author hl* @Date 2022/12/6 10:35* @Version 1.0*/
@AllArgsConstructor
public class ImgCodeFilter implements Filter {private final StringRedisTemplate redisTemplate;private final static String AUTH_URL = "/login";public static final String DEFAULT_CODE_KEY = "random_code_";/*** filter对象只会创建一次,init方法也只会执行一次。*/@Overridepublic void init(FilterConfig filterConfig) throws ServletException {Filter.super.init(filterConfig);}/*** 主要的业务代码编写方法*/@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {//只有转换为HttpServletRequest 对象才可以获取路径参数HttpServletRequest request = (HttpServletRequest) servletRequest;String requestURI = request.getRequestURI();if (!AUTH_URL.equalsIgnoreCase(requestURI)){//放行filterChain.doFilter(servletRequest, servletResponse);}try {String bodyStr = resolveBodyFromRequest(request);JSONObject bodyJson=JSONObject.parseObject(bodyStr);String code = (String) bodyJson.get("captcha");String randomStr = (String) bodyJson.get("randomStr");// 校验验证码checkCode(code, randomStr);} catch (Exception e) {HttpServletResponse response = (HttpServletResponse) servletResponse;response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());response.setHeader("Content-Type", "application/json;charset=UTF-8");response.sendError(HttpStatus.UNAUTHORIZED.value(),"验证码认证失败或者过期");}filterChain.doFilter(servletRequest, servletResponse);}/*** 检查code*/@SneakyThrowsprivate void checkCode(String code, String randomStr) {if (StringUtils.isBlank(code)) {throw new RuntimeException("验证码不能为空");}if (StringUtils.isBlank(randomStr)) {throw new RuntimeException("验证码不合法");}String key = DEFAULT_CODE_KEY + randomStr;String result = redisTemplate.opsForValue().get(key);redisTemplate.delete(key);if (!code.equalsIgnoreCase(result)) {throw new RuntimeException("验证码不合法");}}/*** @MethodName resolveBodyFromRequest* @Description 不能和@Requestbody搭配使用* 原因: getInputStream() has already been called for this request,流不能读取第二次,@Requestbody已经读取过一次了* @param request 请求流* 解决方案: 重写HttpServletRequestWrapper类,将HttpServletRequest的数据读到wrapper的缓存中去(用 byte[] 存储),再次读取时读缓存就可以了* 当接口涉及到上传下载时,会有一些异常问题,最好在过滤器中排除这些路径* @return: java.lang.String* @Author hl* @Date 2022/12/6 15:18*/private String resolveBodyFromRequest(HttpServletRequest request){String bodyStr = null;// 获取请求体if ("POST".equalsIgnoreCase(request.getMethod())){try {bodyStr = request.getReader().lines().collect(Collectors.joining(System.lineSeparator()));} catch (IOException e) {throw new RuntimeException(e);}}return bodyStr;}/*** 在销毁Filter时自动调用。*/@Overridepublic void destroy() {Filter.super.destroy();}
}
加入到配置中
这里校验需要用到redis,用构造方法给他注入
@Autowiredprivate StringRedisTemplate redisTemplate;@Beanpublic FilterRegistrationBean> imgCodeFilterRegistration() {FilterRegistrationBean registration = new FilterRegistrationBean<>();// 将过滤器配置到FilterRegistrationBean对象中registration.setFilter(new ImgCodeFilter(redisTemplate));// 给过滤器取名registration.setName("imgCodeFilter");// 设置过滤器优先级,该值越小越优先被执行registration.setOrder(2);List urlPatterns = new ArrayList<>();urlPatterns.add("/login");// 设置urlPatterns参数registration.setUrlPatterns(urlPatterns);return registration;}
遇到的问题及解决思路
问题:流不能多次被调用
ERROR m.e.handler.GlobalExceptionHandler - getInputStream() has already been called for this request
java.lang.IllegalStateException: getInputStream() has already been called for this requestat org.apache.catalina.connector.Request.getReader(Request.java:1212)at org.apache.catalina.connector.RequestFacade.getReader(RequestFacade.java:504)
根据报错信息分析简单来说,就是getInputStream()已经被调用了,不能再次调用。可是我看代码上,我也没调用。经过一番检索,原来@RequestBody注解配置后,默认会使用流来读取数据
具体原因:
实测,不加@RequestBody注解,可以如期获得请求中的json参数,但是又不得不加@RequestBody注解。这样就需要新的思路
解决思路:
写filter继承HttpServletRequestWrapper,缓存InputStream,覆盖getInputStream()和getReader()方法,使用ByteArrayInputStream is = new ByteArrayInputStream(body.getBytes());读取InputStream。下面自定义BodyReaderFilter和BodyReaderWrapper就是具体解决方法
BodyReaderWrapper
package com.yolo.springboot.kaptcha.filter;import org.springframework.util.StreamUtils;import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;/*** 自定义 BodyReaderWrapper* 问题原因:在controller中我们通过@RequestBody注解来获取前端传过来的json数据,这里已经使用了一次request来获取body中的值。再次通过request获取body中的值,就会报错* 使用场景:通过request能获取到一次body中的值,有时候我们需要多次获取body中的值的需求,因此需要对流再次封装再次传递*/
public class BodyReaderWrapper extends HttpServletRequestWrapper {private byte[] body;public BodyReaderWrapper(HttpServletRequest request) throws IOException {super(request);//保存一份InputStream,将其转换为字节数组body = StreamUtils.copyToByteArray(request.getInputStream());}//转换成Stringpublic String getBodyString(){return new String(body,StandardCharsets.UTF_8);}@Overridepublic BufferedReader getReader() {return new BufferedReader(new InputStreamReader(getInputStream()));}//把保存好的InputStream,传下去@Overridepublic ServletInputStream getInputStream() {final ByteArrayInputStream bais = new ByteArrayInputStream(body);return new ServletInputStream() {@Overridepublic int read() {return bais.read();}@Overridepublic boolean isFinished() {return false;}@Overridepublic boolean isReady() {return false;}@Overridepublic void setReadListener(ReadListener readListener) {}};}public void setInputStream(byte[] body) {this.body = body;}
}
BodyReaderFilter
package com.yolo.springboot.kaptcha.filter;import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import org.apache.commons.lang3.StringUtils;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;/*** @ClassName RequestFilter* @Description 自定义BodyReaderFilter解决读取controller中使用@Requestbody重复读取流错误问题* @Author hl* @Date 2022/12/6 15:44* @Version 1.0*/
public class BodyReaderFilter implements Filter {private List noFilterUrls;@Overridepublic void init(FilterConfig filterConfig){// 从过滤器配置中获取initParams参数String noFilterUrl = filterConfig.getInitParameter("noFilterUrl");// 将排除的URL放入成员变量noFilterUrls中if (StringUtils.isNotBlank(noFilterUrl)) {noFilterUrls = new ArrayList<>(Arrays.asList(noFilterUrl.split(",")));}}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {ServletRequest requestWrapper = null;String requestURI = null;if (servletRequest instanceof HttpServletRequest) {//获取请求中的流如何,将取出来的字符串,再次转换成流,然后把它放入到新request对象中。requestWrapper = new BodyReaderWrapper((HttpServletRequest) servletRequest);requestURI = ((HttpServletRequest) servletRequest).getRequestURI();}//如果请求是需要排除的,直接放行,例如上传文件if ((CollUtil.isNotEmpty(noFilterUrls) && StrUtil.isNotBlank(requestURI) && noFilterUrls.contains(requestURI)) || requestWrapper == null){chain.doFilter(servletRequest, servletResponse);}else {// 在chain.doFiler方法中传递新的request对象chain.doFilter(requestWrapper, servletResponse);}}@Overridepublic void destroy() {Filter.super.destroy();}
}
加入到配置中
这里需要注意,拦截的是所有请求,上传文件的时候需要排除,上传文件的路径
@Beanpublic FilterRegistrationBean> bodyReaderFilterRegistration() {FilterRegistrationBean registration = new FilterRegistrationBean<>();// 将过滤器配置到FilterRegistrationBean对象中registration.setFilter(new BodyReaderFilter());// 给过滤器取名registration.setName("bodyReaderFilter");// 设置过滤器优先级,该值越小越优先被执行registration.setOrder(1);List urlPatterns = new ArrayList<>();//这里需要填写排除上传文件的接口Map paramMap = new HashMap<>();paramMap.put("noFilterUrl", "/test");// 设置initParams参数registration.setInitParameters(paramMap);urlPatterns.add("/*");// 设置urlPatterns参数registration.setUrlPatterns(urlPatterns);return registration;}
测试成功:这里我原本用的form-data传参,然后一直获取到body为空,用这种方法是需要在raw中进行填写的
获取form表单的数据
//方式一:getParameterMap(),获得请求参数mapMap map= request.getParameterMap(); //key 参数名称 value:具体值//方式二:getParameterNames():获取所有参数名称Enumeration a = request.getParameterNames();
自定义的过滤器不要交给spring管理,也就是说不要添加@Component注解,不然每一个请求都会进行过滤