美文网首页
实战系列:(一)由MD5签名失败引发的思考

实战系列:(一)由MD5签名失败引发的思考

作者: foundwei | 来源:发表于2019-07-05 13:39 被阅读0次

写在前面

       网上很多的文章都是教科书式的说教,缺乏实用价值。这也是笔者想写此系列文章的初衷,希望把实际工作的实战经验分享给大家,帮助大家解决实际问题。后续的一系列文章都是笔者在实际工作遇到的问题,比较具有代表性,从实战的角度进行分析总结,希望能够给大家带来帮助。

一、问题背景

       老套路,先说一下问题的背景,然后再展开讨论。
       笔者日前在参与一个网上商城的项目,使用Java语言、Spring Boot框架、Mysql数据库、MyBatis作为OR Mapping工具。其中一个功能是通过第三方的API接口向发货商的ERP系统推送订单信息。调用第三方API接口时需要对请求参数进行签名,第三方服务器会对该请求参数进行合法性验证。这也是大多数第三方接口调用的通用方式,通过使用第三方提供的SECRET和请求参数拼接,然后对拼接字符串进行MD5签名。第三方要求请求服务时统一使用UTF-8编码,需要对url及入参进行 encoding,post方式需要设置request里的Content-Type为 application/x-www-form-urlencoded。
       完全遵循第三方的要求进行签名与请求发送,在开发机上一切正常,调用接口没有任何问题。本以为万事大吉,满怀信心地交给测试人员进行测试,优哉优哉了!没成想,测试同学直接一个bug扔回来,推送订单失败!不应该呀!一脸的懵圈中……,按照开发人员的惯性思维,肯定是你的测试数据有问题,很不服气的样子!但是作为一个负责任的老码农,不能无端的指责别人,先看看什么原因再下结论。先把服务器上的log翻出来看了一下,服务器端返回的错误说明是:签名验证失败!不可能啊!赶紧在自己的开发机上用同样的数据请求了一遍,成功了!没有问题呀!怎么在服务器上就签名失败呢?肯定是服务器配置的问题,又犯老毛病了:)。是不是服务器配置的问题,要找到证据才有说服力。

二、问题定位

       还是不死心,又在开发机和服务器试了几遍之后,终于接受现实了。这个问题怎们排查呢?同样的数据调用同样的签名代码,却在不同的机器上得到不同的结果。服务器上面没有办法debug,那就把log打印出来对比一下喽!在代码中打印出需要签名的字符串以及得到的签名结果,在开发机和服务器上分别运行。很快就发现问题了,因为需要签名的字符串中包含中文字符,在我的开发机上打印正常,可是在服务器上中文部分打印出来的却是乱码,这应该就是问题的原因所在,基本可以确定是因为编码不一致引起的。

三、解决方案

       定位到问题的原因就好办了,首先我们开发过程中明确要求字符编码统一使用UTF-8,并且第三方也是明确要求使用UTF-8编码的,所以在我开发机的Java启动项中有-Dfile.encoding=UTF-8设置,而服务器上部署时却没有加该配置项。那服务器部署时加上这个配置项不就解决问题了?是的,没错!如果仅到此为止,那就没意思了。只是停留在解决问题的层面,终究不是学习之道。本着寻根问底的科学精神,我们要继续探索更深层次的原因。

四、深入分析

       又把代码过了一遍,发现整个签名流程当中和字符编码有关的步骤可能发生在MD5计算的过程中,于是找到相关代码,如下:

public static String getMD5(String source) throws NoSuchAlgorithmException, UnsupportedEncodingException {
    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(source.getBytes());
    byte[] md5Bytes = md.digest();
    StringBuilder res = new StringBuilder();
    for (int i = 0; i < md5Bytes.length; i++){
        int t = md5Bytes[i] & 0xff;
        if (t <= 0xf){
            res.append("0");
        }
        res.append(Integer.toHexString(t));
    }
    return res.toString();
}

这里有个步骤是先获取待签名字符串的字节码,经过计算之后再转成字符串。md.update(source.getBytes());这行代码用来获取字符串的字节数组,应该和字符编码有关。找出其源代码来看看:

/**
     * Encodes this {@code String} into a sequence of bytes using the
     * platform's default charset, storing the result into a new byte array.
     *
     * <p> The behavior of this method when this string cannot be encoded in
     * the default charset is unspecified.  The {@link
     * java.nio.charset.CharsetEncoder} class should be used when more control
     * over the encoding process is required.
     *
     * @return  The resultant byte array
     *
     * @since      JDK1.1
     */
    public byte[] getBytes() {
        return StringCoding.encode(value, 0, value.length);
    }

果不其然,不带参数的getBytes方法会使用平台默认的字符集(the platform’s default charset)把字符串编码为字节序列。这个平台默认的字符集是什么?继续深挖下去,查看StringCoding.encode方法的源代码:

static byte[] encode(char[] ca, int off, int len) {
        String csn = Charset.defaultCharset().name();
        try {
            // use charset name encode() variant which provides caching.
            return encode(csn, ca, off, len);
        } catch (UnsupportedEncodingException x) {
            warnUnsupportedCharset(csn);
        }
        try {
            return encode("ISO-8859-1", ca, off, len);
        } catch (UnsupportedEncodingException x) {
            // If this code is hit during VM initialization, MessageUtils is
            // the only way we will be able to get any kind of error message.
            MessageUtils.err("ISO-8859-1 charset not available: "
                             + x.toString());
            // If we can not find ISO-8859-1 (a required encoding) then things
            // are seriously wrong with the installation.
            System.exit(1);
            return null;
        }
    }

首先使用系统默认的字符集(Charset.defaultCharset)进行编码,系统不支持该字符集时,就改用ISO-8859-1字符集进行编码,如果连ISO-8859-1字符集都不支持的话,虚拟机就直接退出了。
Charset.defaultCharset到底取的是那个值呢?再往下追:

private static volatile Charset defaultCharset;

/**
     * Returns the default charset of this Java virtual machine.
     *
     * <p> The default charset is determined during virtual-machine startup and
     * typically depends upon the locale and charset of the underlying
     * operating system.
     *
     * @return  A charset object for the default charset
     *
     * @since 1.5
     */
    public static Charset defaultCharset() {
        if (defaultCharset == null) {
            synchronized (Charset.class) {
                String csn = AccessController.doPrivileged(
                    new GetPropertyAction("file.encoding"));
                Charset cs = lookup(csn);
                if (cs != null)
                    defaultCharset = cs;
                else
                    defaultCharset = forName("UTF-8");
            }
        }
        return defaultCharset;
    }

妖猴哪里走?还不显出原形!
       这段注释说的很明确了,简单说就是默认字符集是在java虚拟机启动时确定的,依赖于java虚拟机所在的操作系统的时区以及字符集。取值是file.encoding选项配置的字符集,如果找不到file.encoding选项配置的字符集,就使用UTF-8字符集。
       对比一下服务器和我的开发机的locale信息:

服务器:/data/services$ locale
LANG=
LANGUAGE=
LC_CTYPE="POSIX"
LC_NUMERIC="POSIX"
LC_TIME="POSIX"
LC_COLLATE="POSIX"
LC_MONETARY="POSIX"
LC_MESSAGES="POSIX"
LC_PAPER="POSIX"
LC_NAME="POSIX"
LC_ADDRESS="POSIX"
LC_TELEPHONE="POSIX"
LC_MEASUREMENT="POSIX"
LC_IDENTIFICATION="POSIX"
LC_ALL=

服务器locale的LANG属性未设置。再看看我的开发机:

Last login: Tue Jun 25 10:01:01 on console
(base) weichenglideMacBook-Pro:~ weichengli$ locale
LANG="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_CTYPE="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_ALL=
(base) weichenglideMacBook-Pro:~ weichengli$

我的开发机设置为UTF-8字符集。
       到现在还是没有弄清楚服务器上到底使用的什么字符集。从Jenkins部署的log中可以清晰地看到服务器的默认编码:

Maven home: /home/jenkins/tools/hudson.tasks.Maven_MavenInstallation/Maven_3
Java version: 1.8.0_181, vendor: Oracle Corporation, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: en_US, platform encoding: ANSI_X3.4-1968
OS name: "linux", version: "4.20.12-1.el7.elrepo.x86_64", arch: "amd64", family: "unix"

platform encoding: ANSI_X3.4-1968,一种工程字符编码,这里不详细介绍。到这里的话,基本上就搞清楚了错误的原因,如果在服务器上java启动的时候不指定file.encoding选项的话,服务器上启动的JVM就默认采用了ANSI_X3.4-1968编码,导致MD5签名失败。

       除了在JVM启动参数里面加-Dfile.encoding=UTF-8这个选项外,也可以修改代码来解决这个问题,就是在MD5签名获取字节数组时指定UTF-8编码,这样更加明确。

public static String getMD5(String source) throws NoSuchAlgorithmException, UnsupportedEncodingException {
    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(source.getBytes("UTF-8"));
    byte[] md5Bytes = md.digest();
    StringBuilder res = new StringBuilder();
    for (int i = 0; i < md5Bytes.length; i++){
        int t = md5Bytes[i] & 0xff;
        if (t <= 0xf){
            res.append("0");
        }
        res.append(Integer.toHexString(t));
    }
    return res.toString();
}

source.getBytes("UTF-8")直接指定UTF-8编码。

五、扩展思考

       科学精神还没有结束,还得继续探索下去。
       String的getBytes()方法,如果你在使用这个方法时不考虑编码这一点,你会发现在一个平台上运行良好的系统,放到另一台机器上会产生意想不到的问题。该方法是和平台(编码)相关的。

       下面我们再来做一个试验,在我自己的开发机上进行。我的开发机上操作系统的默认字符集为UTF-8。该试验的目的是检验一下file.encoding选项与Charset.defaultCharset的关系。

private static volatile Charset defaultCharset;

/**
     * Returns the default charset of this Java virtual machine.
     *
     * <p> The default charset is determined during virtual-machine startup and
     * typically depends upon the locale and charset of the underlying
     * operating system.
     *
     * @return  A charset object for the default charset
     *
     * @since 1.5
     */
    public static Charset defaultCharset() {
        if (defaultCharset == null) {
            略
        }
        return defaultCharset;
    }

       从前面的分析我们知道默认字符集(defaultCharset)是在java虚拟机启动时确定的,依赖于java虚拟机所在的操作系统的时区以及字符集。其实这个默认字符集(defaultCharset)确切的说应该是JVM(虚拟机)的字符集,而且defaultCharset这个变量是个private的static变量。按照以上代码的逻辑这个defaultCharset设置之后就不会再更改了,直到JVM退出。只有在defaultCharset等于null的时候才会取file.encoding的值进行设置,之后即使在JVM运行期间通过System.setProperty()方法修改了file.encoding的值,defaultCharset也不会跟着改变。
       通过以下代码来验证一下喽!

import java.nio.charset.Charset;

public class Test {
    public static void main(String[] args) {
        System.out.println(System.getProperty("file.encoding"));
        System.out.println(Charset.defaultCharset().name());
        System.out.println(Charset.defaultCharset().displayName());
        System.setProperty("file.encoding", "GBK");
        System.out.println("Change the value of file.encoding");
        System.out.println(System.getProperty("file.encoding"));
        System.out.println(Charset.defaultCharset().name());
        System.out.println(Charset.defaultCharset().displayName());
    }
}

编译:javac Test.java
运行:java Test
输出结果为:

(base) weichenglideMacBook-Pro:Desktop weichengli$ java Test
UTF-8
UTF-8
UTF-8
Change the value of file.encoding
GBK
UTF-8
UTF-8
(base) weichenglideMacBook-Pro:Desktop weichengli$

改变一下配置再运行:java –Dfile.encoding=GB2312 Test
输出结果为:

(base) weichenglideMacBook-Pro:Desktop weichengli$ java -Dfile.encoding=GB2312 Test
GB2312
GB2312
GB2312
Change the value of file.encoding
GBK
GB2312
GB2312
(base) weichenglideMacBook-Pro:Desktop weichengli$

通过这个试验说明了file.encoding选项与Charset.defaultCharset的关系。

       除了getBytes()方法使用defaultCharset以外,字符流的读取和写入同样使用了该属性。还是以代码为证:

/**
     * Creates an InputStreamReader that uses the default charset.
     *
     * @param  in   An InputStream
     */
    public InputStreamReader(InputStream in) {
        super(in);
        try {
            sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
        } catch (UnsupportedEncodingException e) {
            // The default encoding should always be available
            throw new Error(e);
        }
    }

InputStreamReader调用StreamDecoder.forInputStreamReader方法:

public static StreamDecoder forInputStreamReader(InputStream var0, Object var1, String var2) throws UnsupportedEncodingException {
        String var3 = var2;
        if (var2 == null) {
            var3 = Charset.defaultCharset().name();
        }

        try {
            if (Charset.isSupported(var3)) {
                return new StreamDecoder(var0, var1, Charset.forName(var3));
            }
        } catch (IllegalCharsetNameException var5) {
        }

        throw new UnsupportedEncodingException(var3);
    }

妥妥滴!如果不指定编码的话,还是使用Charset.defaultCharset。

       好了,到这里,就到这里,再见吧!

                                                                             2019年6月27日晚 于北京通州家中

相关文章

网友评论

      本文标题:实战系列:(一)由MD5签名失败引发的思考

      本文链接:https://www.haomeiwen.com/subject/uyoshctx.html