关于servlet利用filter将参数进行加解密

问题描述

我们在日常开发过程中,经常会有在filter中对请求参数进行统一修改,然后对响应数据也要做统一修改的需求。比如出于安全考虑,我们会将请求参数和响应数据进行加密,这个时候就需要在filter中将请求参数进行解密,给后续的接口使用,而且还要将接口响应的数据进行加密,返回给请求端,这里面就涉及到两个问题:数据的加解密和请求数据的修改。

数据加解密

一般我们都会使用https来保证我们和服务端的通信安全,https协议可以保证我们通信的数据是加密的,不会被别人抓包,但是对于一些对安全性要求比较高的公司,https协议还不够,这个时候就需要我们自己来对通信数据加密了。

加密方式

一般来说非对称加密要比对称加密的安全性要更高一些,但是非对称加密的加密算法效率较低,会严重影响我们系统的响应时间和QPS,总结来说:

  • 对称加密优点:加密算法效率较高;
  • 对称加密缺点:安全性较低,相同数据加密出来的密文是一样的,容易被破解;
  • 非对称加密优点:安全性较高,加密出来的密文不一样,公钥加密,私钥解密,极不容易被破解;
  • 非对称加密缺点:加密算法效率较低;
    综上,我们决定取两种加密算法的优点:使用对称加密来加密业务数据来提高加密效率,同时对称加密的密钥使用动态密钥,然后用非对称加密来加密密钥数据,也可以加密一些验证信息,来提高安全性。

RSA

一种非对称的加密算法,它的密钥分为公钥和私钥,公钥是大家都知道的,用来加密,私钥只有我们自己知道,用来解密,公钥和私钥是成对的,这个不是重点,直接上一下代码实现吧。

public static List genKeyPair() throws NoSuchAlgorithmException {  
		// KeyPairGenerator类用于生成公钥和私钥对,基于RSA算法生成对象  
		KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");  
		// 初始化密钥对生成器,最小为512
		keyPairGen.initialize(512,new SecureRandom());  
		// 生成一个密钥对,保存在keyPair中  
		KeyPair keyPair = keyPairGen.generateKeyPair();  
		RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();   // 得到私钥  
		RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();  // 得到公钥  
		List result = new ArrayList<>(2);
		//公钥
		result.add(new String(Base64Util.encode(publicKey.getEncoded())));
		//私钥
		result.add(new String(Base64Util.encode((privateKey.getEncoded()))));
		return result;
	}

public static String encrypt(String str, String publicKey) throws Exception{
		//base64编码的公钥
		byte[] decoded = Base64Util.decodeb(publicKey);
		RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
		//RSA加密
		Cipher cipher = Cipher.getInstance("RSA");
		cipher.init(Cipher.ENCRYPT_MODE, pubKey);
		String outStr = Base64Util.encode(cipher.doFinal(str.getBytes("UTF-8")));
		return outStr;
	}

public static String decrypt(String str, String privateKey) throws Exception{
		//64位解码加密后的字符串
		byte[] inputByte = Base64Util.decodeb(str);
		//base64编码的私钥
		byte[] decoded = Base64Util.decodeb(privateKey);  
        RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decoded));  
		//RSA解密
		Cipher cipher = Cipher.getInstance("RSA");
		cipher.init(Cipher.DECRYPT_MODE, priKey);
		String outStr = new String(cipher.doFinal(inputByte));
		return outStr;
	}

这个代码实现网上很多,随便一搜就有。

AES

一种对称加密算法,它对密钥的长度有一定的要求,还有五种加密模式,还需要指的固定的偏移量,这些在实现的时候一定要注意,也直接上实现代码吧。

	public static byte[] encrypt(String content, String secretKey) throws Exception {
		if(secretKey == null || secretKey.length() != 16) {
			return null;
		}
		
        byte[] encrypted = getCipher(Cipher.ENCRYPT_MODE, secretKey).doFinal(content.getBytes());
        return encrypted;
//        return Base64Util.encode(encrypted);
	}

	public static String decrypt(String encryptContent, String secretKey) throws Exception {
		return decrypt(Base64.getDecoder().decode(encryptContent), secretKey);
	}
	
	public static String decrypt(byte[] encryptContent, String secretKey) throws Exception {
		logger.info("encryptContent: " + encryptContent + ", secretKey: " + secretKey);
		if(secretKey == null || secretKey.length() != 16) {
			return SECRET_KEY_ERROR;
		}
		
		logger.info("encryptContent length: " + encryptContent.length);
		byte[] original = getCipher(Cipher.DECRYPT_MODE, secretKey).doFinal(encryptContent);
		return new String(original);
	}
	
	private static Cipher getCipher(int mode, String secretKey) throws Exception {
		IvParameterSpec iv = new IvParameterSpec(INIT_VECTOR.getBytes("UTF-8"));
        SecretKeySpec skeySpec = new SecretKeySpec(secretKey.getBytes("UTF-8"), "AES");

        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
        cipher.init(mode, skeySpec, iv);
        return cipher;
	}

还有一点:我们一般都会把结果进行Base64编码,因为加密出来的数据是二进制的,不能打印出来,但是有时也会觉得Base64编码是多余的,因为编码之后,其他端还要解码,会直接使用二进制通信,这个时候就要直接使用加密出来的二进制数据了。
附一个在线加密的网站:http://tool.chacuo.net/

修改请求和响应数据

修改请求数据

一般我们想到的修改请求数据的流程:

  1. 创建一个过滤器;
  2. 读取request中的请求数据;
  3. 修改读取到的数据,再塞回到request对象中;

这里有几个问题:

  • 因为request中的流只能读取一次,所以这里读取了之后,后面会流已经关闭的情况;
java.io.IOException: Stream closed
java.lang.IllegalStateException: getReader() has already been called for this request
  • 修改之后的请求参数无法重新添加到body中去;
  • 读取二进制数据只能ServletInputStream对象,但是用到这个对象又会导致流关闭;

针对这些问题,一般会创建一个自己的类继承与HttpServletRequestWrapper类,重写里面的getInputStream()方法,具体代码如下:

public class MyRequestWapper extends HttpServletRequestWrapper {

    //请求数据
    private String body;
    //请求二进制数据
    private byte[] bytes;

    public MyRequestWapper(HttpServletRequest request) throws IOException {
        super(request);

        try (ServletInputStream inputStream = request.getInputStream()){
            bytes = new byte[request.getContentLength()];
            inputStream.read(bytes);

            body = new String(bytes);
        }
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        //这一步最关键
        //这一步使得后续获取InputStream都是这个对象
        //而在此时我们也把我们自定义的数据塞进去了
        //也就是说后续处理中获取到的参数就是我们此时塞进去的数据
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
        ServletInputStream inputStream = new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {

            }
			
			//这里流的读取,就是从我们自定义流中读取数据
            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }
        };
        return inputStream;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }

    public byte[] getBytes() {
        return bytes;
    }

    public void setBytes(byte[] bytes) {
        this.bytes = bytes;
    }
}

简单介绍下上面的代码吧,主要有几点:

  1. 上面有提到过,我们的数据流其实是只能读取一次的,在 try (ServletInputStream inputStream = request.getInputStream())这行代码时,HttpServletRequest中的流已经被读取过一次了,后续就不能再读取这个流了,所以我们需要在getInputStream()方法中新建一个流,将我们的自己的数据塞进去,供后续操作使用;
  2. 想要读取到二进制数据,我们需要用ServletInputStream对象去读取,不能使用任何一个装饰对象,因为装饰对象会将二进制数据转成String对象,这样读取到的数据就是错误的了;
  3. 在构造方法中我们获取到了HttpServletRequest对象中的请求数据,并赋值给了bytes变量,在 final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);这句代码中,又将bytes数据写到了新创建的流里面,所以我们可以通过这个变量的值来控制请求参数。

修改响应数据

整个流程和修改请求数据差不多,继承HttpServletResponseWrapper类,重写getOutputStream()方法,先上代码吧:

public class MyResponseWapper extends HttpServletResponseWrapper {

    //我们获取响应数据的流
    private ByteArrayOutputStream byteArrayOutputStream;

    public MyResponseWapper(HttpServletResponse response) {
        super(response);
        byteArrayOutputStream = new ByteArrayOutputStream();
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        return new ServletOutputStream() {
            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setWriteListener(WriteListener writeListener) {

            }

            //这个最重要,是在这里将响应数据写到这个类的byteArrayOutputStream中的,
            //这样我们后续才能拿到响应数据
            //也正是因为这样,实际的HttpResponse对象中是没有被写入数据的
            @Override
            public void write(int b) throws IOException {
                byteArrayOutputStream.write(b);
            }
        };
    }

    public byte[] toByteArray(){
        byte[] bytes = byteArrayOutputStream.toByteArray();
        System.out.println(bytes.length);
        return bytes;
    }
}

代码很简单,创建了我们自己的一个输出流,然后在ServletOutputStream类中的write(int b)方法中,用这个输出流来接收原本应该写入HttpServletResponse中的响应数据,这里要注意几点:

  1. 这里我们只是接收到了响应数据,并没有改变响应数据;
  2. 因为我们用了我们自己的ByteArrayOutputStream流来接收了数据,所以HttpServletResponse中的流是没有数据的,所以我们必须要写入数据,这样才会有响应数据;
  3. 这里我们并没有用到HttpServletResponse中的流,所以不存在流只能用一次的问题;

Filter中的使用

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

        MyRequestWapper requestWapper = new MyRequestWapper((HttpServletRequest) servletRequest);
        MyResponseWapper responseWapper = new MyResponseWapper((HttpServletResponse) servletResponse);
        //获取请求参数的二进制数据
        byte[] requestByte = requestWapper.getBytes();
        //获取请求参数字符串
        String requestBody = requestWapper.getBody();
        System.out.println("requestBody: " + requestBody);
        //替换请求参数
        String newBody = "{\"name\": \"aa\", \"age\": 21}";
        requestWapper.setBytes(newBody.getBytes());
        //继续后续请求处理
        filterChain.doFilter(requestWapper, responseWapper);

        //获取响应数据
        String response = new String(responseWapper.toByteArray());
        System.out.println(response);

        byte[] rewriteResult = "{\"code\": 1000, \"msg\": success}".getBytes();
        //往原始的HttpResponse对象中写入数据
        responseWapper.setContentLength(rewriteResult.length);
        try(OutputStream out = servletResponse.getOutputStream()){
            out.write(rewriteResult);
            out.flush();
        }
    }

这个代码里就有我上面提到的几个点了:

  • 获取请求数据,就是获取MyRequestWapper类中bytes属性的值;
  • 修改请求数据,就是修改MyRequestWapper类中bytes属性的值;
  • 获取响应数据,就是获取MyResponseWapper类中的byteArrayOutputStream流中的数据;
  • 写入响应数据,这里要注意一些,这里必须要往原来的HttpServletResponse对象中写,因为这个对象里的流才是返回给客户端的,要是往我们自定义的MyResponseWapper的对象中写,是不会返回给客户端的;

关于content-length的一个坑

注意responseWapper.setContentLength(rewriteResult.length);这行代码,如果少了这行代码,可能会使客户端接收到的数据少了,或者一直处于等待数据的状态,原因是在http协议中,content-length表示数据内容的长度,客户端会根据这个字段的值来接收这个值大小的数据,而如果我们修改了响应数据,但是没有修改这个字段的值的话,如果我们修改的数据比原本的数据少的话,客户端会一直处于等待数据的状态,它会以为还有数据没有传送过来;而如果我们修改的数据多的话,客户端也只是接收一部分数据,所以在修改响应结果时,一定要记得修改下这个参数的值。

你可能感兴趣的:(日常开发问题,java,http,加密解密,servlet)