在说明URL编码之前,先回顾编码和字符集的相关基础知识,再引出为什么URL中需要做编码,以及如何编码的。最后顺便介绍了下HTTP的身份认证问题。这些知识可谓是基础中的基础,重点中的重点了,不仅HTTP,在任何地方都会遇到此类问题。希望我们自己都能把基础夯实好,发车!
一、字符编码问题回顾
与计算机打交道,字符编码问题是永远需要面对的,我决定花点时间把各种字符集、编码搞清楚。希望能搞清楚字符集和编码这两件事。
如果你是一个生活在2003年的程序员,却不了解字符、字符集、编码和Unicode这些基础知识。那你可要小心了,要是被我抓到你,我会让你在潜水艇里剥六个月洋葱来惩罚你。
这个邪恶的恐吓是Joel Spolsky
在十几年前首次发出的。不幸的是,很多人认为他只是在开玩笑,因此,现在仍有许多人不能完全理解Unicode,以及Unicode, UTF-8, UTF-16之间的区别。包括我自己,写完这篇文章,我也感慨编码这件事没有那么简单,但是希望先明确一些概念,捞一些重点。
1.1 什么是编码和解码
众所周知,程序中的所有信息都是以二进制的形式存储在计算机的底层,也就是说我们在代码中定义的一个 char
字符或者一个 int
整数都会被转换成二进制码储存起来,这个过程可以被称为编码,而将计算机底层的二进制码转换成屏幕上有意义的字符(如hello world
),这个过程就称为解码。
1.2 什么是字符集
在计算机中字符的编解码就涉及到字符集(Character Set
)这个概念,他就相当于能够将一个字符与一个整数一一对应的一个映射表,常见的字符集有 ASCII
、Unicode
等。
1.3 ASCII 编码
历史中的很长一段时间里,计算机仅仅应用在欧洲的一些发达国家,因此在他们的程序中只存在他们所理解的拉丁字母(如a
、b
、c
、d
等)和阿拉伯数字,他们在编码解码时也只需要考虑这一种情况,就是如何将这些字符转换成计算机所能理解的二进制数,此时 ASCII
字符集应运而生,他们在编码时只需要对照着 ASCII
字符集,每当在程序中遇到字符 a
时,就会相应的找到其中 a
对应的 ASCII
值 97 然后以二进制形式存起来即可。
ASCII
字符集支持 128 种字符,仅使用 7 个 bit
位,也就是一个字节的后 7 位就可以将它们全部表示出来,而最前面的一位统一规定为 0 即可(如 0110 0001
表示 a)。对应表贴下:
因此ASCII 字符集包含的范围很小,仅包括了控制字符(回车键、退格、换行键等)和可显示字符(英文大小写字符、阿拉伯数字和西文符号)。
后来,为了能够表示更多的欧洲国家的常用字符如法语中带符号的字符 é
,又制定了 ASCII
额外扩展的版本 EASCII
,这里就可以使用一个完整子节的 8 个 bit 位表示共 256 个字符,其中就又包括了一些衍生的拉丁字母。
显然,在ASCII 编码中,一个字母只需要占用一个字节。
1.4 GB2312 字符集
ASCII
字符集支持范围太小了,不能满足需求,我们国家也为此做了扩展,最具代表性的就是国内的 GB 类的汉字编码模式,这种模式规定:ASCII
值小于 127 的字符的意义与原来 ASCII
集中的字符相同,但当两个 ASCII
值大于 127 的字符连在一起时,就表示一个简体中文的汉字,前面的一个字节(高字节)从 0xA1
拓展到 0xF7
,后面一个字节(低字节)从 0xA1
到 0xFE
,这样就可以组合出了大约 7000 多个简体汉字了。
为了在解码时操作的统一,GB 类编码表中还也加入了数学符号、罗马希腊的字母、日文的假名等,连在 ASCII 里本来就有的数字、标点、字母都统一重新表示为了两个字节长的编码,这就是我们常说的 “全角” 字符,而原来在 127 号以下的那些就叫 “半角” 字符了,这种编码规则就是后来的 GB2312。
因此我们可以知道,在GB2312字符集中,一个汉字要占两个字节。
1.5 GBK编码标准的由来
不过中国的汉字又实在是太多了,人们很快就发现 GB2312
字符集只能够那点汉字明显不够(如中国前总理朱镕基的 “镕” 字并不在 GB2312
字符集中),于是专家们又继续把 GB2312
没有用到的码位使用到其他没有被收录的汉字中。
后来还是不够用,于是干脆规定只要第一个字节是大于 127 就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。结果扩展之后的编码方案被称为 GBK 标准,GBK 包括了 GB2312 的所有内容,同时又增加了近 20000 个新的汉字(包括繁体字)和符号。
显然,GBK是咱们国家基于GB2312字符集的扩展,一个汉字仍然占用两个字节。
1.6 GBK的新问题
当时的各个国家都像中国这样制定出了一套自己的编码标准,之后当我们需要使用计算机与国际接轨时,问题出现了!国家与国家之间谁也不懂谁的编码,130 在法语编码中代表了 é
,在希伯来语编码中却代表了字母 Gimel
,在俄语编码中又会代表另一个符号。但是所有这些编码方式中,0—127 表示的符号依然都是一样的,因为他们都兼容 ASCII 码,这一点,如今也是一样。
自然而然地,就需要有一个世界能统一使用的字符集才行。
1.7 Unicode字符集
正如上面所说,世界上各国都有不同的编码方式,同一个二进制数字可以被解码成不同的符号。因此,要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。为了解决这个问题,最终的集大成者 Unicode
字符集出现了,它将世界上所有的符号都纳入其中,成功实现了每个数字代表唯一的至少在某种语言中使用的符号,目前,Unicode
字符集中已经收录超过 13 万个字符(第十万个字符在2005年获采纳)。值得关注的是,Unicode
依然兼容 ASCII
,即 0~127 意义依然不变。
ASCII
码中有个编号,比如 a
对应的 ASCII
值 97。同样地,Unicode
字符集中也有唯一表示某个字符的标识,叫做码点(Code Point
),如码点 U+0061
,这里的 61 就是 97 的十六进制表示,它就表示 Unicode
字符集中的字符 ‘a‘。
码点的表示的形式为 U+[XX]XXXX
,X 代表一个十六制数字,一般可以有 4-6 位,不足 4 位前补 0 补足 4 位,超过则按是几位就是几位,具体范围是 U+0000~U+10FFFF
,大概是 111 万。按 Unicode
官方的说法,码点范围就这样了,以后也不扩充了,一百多万足够用了,目前也只定义了 11 万多个字符左右。
码点从0开始,为每个符号指定一个编号,这叫做”码点”(code point)。比如,码点 0 的符号就是 null(表示所有二进制位都是 0)。
U+0000 = null
整个编码过程中码点就作为了一个中间的过渡层,可用下面这张图来表示:
从这张图可以看出,整个解码可分为两个过程。首先,将程序中的字符根据字符集中的编号数字化为某个特定的数值,然后根据编号以特定的方式存储到计算机中。
显然,这时候我们就可以发现编号并不是最终存储在计算机中的结果。按照之前的理解,编码即把一个字符编码为一个二进制数字存储起来,然而这种表述并不准确,真正的编码不止这么简单,这其中还涉及了每个数字用几个字节表示,是用定长还是变长表示等具体细节。
举个例子,字符 a 的码点为 U+0061
(十进制为 97),那么这个 U+0061
该如何存储,单纯的表示 U+0061
可以直接使用 7 位的二进制数 110 0001
表示,但在 GB 类的编码模式中就需要以两个字节存储即 0000 0000 0110 0001
(空位用 0 填充)。
1.8 字符集不等于编码!
Unicode
字符集衍生出来的编码方案有三种,分别是 UTF-32
、UTF-16
和 UTF-8
,这使他与之前的编码模式不同,因为 ASCII
、GBK
等类编码模式的字符集和编码方式都是一一对应的,而 Unicode
的编码实现却有三种,这就是我们需要区分字符集与编码的原因之一,因为此时 Unicode
并不特指 UTF-8
或者 UTF-32
。
1.9 UTF8、UTF32、UTF16是如何编码和解码的?
下面,我们来看下面这张示意图,探究各种编码模式下,码点是如何具体转换成各种编码的:
这其中又涉及到编码过程中定长与变长两种实现方式,这里的 UTF-32
就属于定长编码,即永远用 4 字节存储码点,而 UTF-8
、UTF-16
就属于变长存储,UTF-8
根据不同的情况使用 1-4 字节,而 UTF-16
使用 2 或 4 字节来存储码点。
由于UTF-32
是定长,固定4个字节一组,这样解码的时候会很简单,不过最大缺点是占用空间太大,因为不管都大的码点都需要四个字节来存储,非常的占空间。
UTF-8
属于变长的编码方式,它可以由 1,2,3,4 四种字节组合,必然需要有个规则来确定到底是哪种长度才能正确解码,它使用的是高位保留的方式来区别不同变长,具体方式如下:
Unicode 码点范围(十六进制) | UTF-8 编码方式(二进制) | 字节数 |
---|---|---|
0000 0000 ~ 0000 007F | 0xxxxxxx | 一个字节 |
0000 0080 ~ 0000 07FF | 110xxxxx 10xxxxxx | 二个子节 |
0000 0800 ~ 0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 三个字节 |
0001 0000 ~ 0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 四个字节 |
在UTF-8
编码方式下,编码字符时就简单了,我们可以先拿到某个汉字的码点,然后根据码点判断所在的范围,确认需要几个字节的编码形式来存储。下面以汉字“汉”为利,具体说明如何进行 UTF-8 编码和解码。
“汉”的 Unicode
码点是 0x6c49
(110 1100 0100 1001
),通过上面的对照表可以发现,0x0000 6c49
位于第三行的范围,那么得出其格式为 1110xxxx 10xxxxxx 10xxxxxx
。接着,从“汉”的二进制数最后一位开始,从后向前依次填充对应格式中的 x,多出的 x 用 0 补上。这样,就得到了“汉”的 UTF-8 编码为 11100110 10110001 10001001
,转换成十六进制就是 0xE6 0xB7 0x89
。
解码 UTF-8
编码也很简单了,如果一个字节的第一位是 0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个 1,就表示当前字符占用多少个字节。
关于UTF-16
,它使用的是一种变长为 2 或 4 字节编码模式。它的编码和方式也略有不同,利用了代理的思想达到跟UTF-8
中高位保留的目的,实际上我到现在也没有接触过这个UTF-16
和UTF-32
,因此不加以深入研究了。
总而言之,在 Unicode
字符集中,就是根据码点和选择的编码方案可以方便地进行编码和解码。
二、URL编码与解码
复习了一波字符集、编码、解码的问题,是因为我在看URL编码和解码,想到了上面的一些概念,索性就一次性都搞定。
在HTTP中,如果某个信息需要编码,说明它不适合直接传输,原因多种多样,如Size过大,包含隐私数据,对于Url来说,之所以要进行编码,是因为Url中有些字符会引起歧义。
一般来说,URL只能使用英文字母、阿拉伯数字和某些标点符号,不能使用其他文字和符号。比如,世界上有英文字母的网址http://www.abc.com
,但是没有希腊字母的网址http://www.aβγ.com
。这是因为网络标准RFC 1738
做了硬性规定:
“…Only alphanumerics [0-9a-zA-Z], the special characters “$-_.+!*’(),” [not including the quotes - ed], and reserved characters used for their reserved purposes may be used unencoded within a URL.”
“只有字母和数字[0-9a-zA-Z]、一些特殊符号”$-_.+!*’(),"[不包括双引号]、以及某些保留字,才可以不经过编码直接用于URL。"
我们也知道,Http协议中参数的传输是key=value
这种简直对形式的,如果要传多个参数就需要用&
符号对键值对进行分割。
如?name1=value1&name2=value2
,这样在服务端在收到这种字符串的时候,会用&
分割出每一个参数,然后再用=
来分割出参数值。我们说下实际解析过程。上述字符串在计算机中用ASCII码表示为:
我们对比ASCII
编码可以知道,=
是61,对应16进制就是3D,&
是38,对应16进制是26,当代码一个字节一个字节吃的时候,吃到3D这字节后,服务端就知道前面吃得字节表示一个key,再想后吃,如果遇到26,说明从刚才吃的3D到26子节之间的是上一个key的value,以此类推就可以解析出客户端传过来的参数。
明白了逻辑后,问题就来了,如果我的参数值中就包含=或&这种特殊字符的时候该怎么办?
比如说name1=value1
,其中value1
的值是va&lu=e1
字符串,那么实际在传输过程中就会变成这样name1=va&lu=e1
。我们的本意是就只有一个键值对,但是服务端会解析成两个键值对,这样就产生了奇异。
这个就属于上面说的不适合直接传输的一个情形,可以通过URL编码来解决。
Url编码通常也被称为百分号编码。为什么这么叫呢?我们来看下它如何编码的,就清楚了。
对于ASCII
字符,比如a
,对应编号是61,那么URL编码之后的结果是%61
,假设我们在地址栏上输入的是http://g.cn/search?q=%61%62%63
,那么等同于在google
中搜索abc
了。那么上面的问题,name1=va&lu=e1
就会被编码为name1=va%26lu%3D
。我们应该知道为什么叫他为百分号编码了。
并且经过学习,我们知道ASCII
字符是不支持中文的。我们先在浏览器上输入中文看看是什么情况,比如我在谷歌浏览器上输入:
http://zh.wikipedia.org/wiki/春节
看了下请求头:
"春"和"节"的utf-8编码分别是E6 98 A5
和E8 8A 82
,因此,%E6%98%A5%E8%8A%82
就是按照顺序,在每个字节前加上%而得到的。可以看出来,这里使用utf-8编码。
再试下?
这种带参数的访问形式,拿http://www.oursnail.cn:8080/fossi-shop/
再来测试下:
可以看到,在我的win10
的谷歌浏览器上实验,对于中文默认使用的是UTF-8
编码。对于 Unicode
字符,RFC
文档建议使用utf-8
对其进行编码得到相应的字节,然后对每个字节执行百分号编码。如"中文"使用UTF-8
字符集得到的字节为0xE4 0xB8 0xAD 0xE6 0x96 0x87
,经过Url编码之后得到%E4%B8%AD%E6%96%87
。
好了,关于URL编码就说到这里。
二、HTTP协议之身份认证
在互联网上,没人知道你是一条狗。为了确认你是你,需要有一套认证机制,常见的认证方式有:
BASIC
认证(基本认证)DIGEST
认证(摘要认证)SSL
客户端认证FormBase
认证(基于表单认证)
既然我们系统学习HTTP,那么下面逐一都简单了解下吧。
2.1 BASIC认证
认证流程如下图所示:
其中是要求用户输入用户名密码的,请求报文首部中,通过Basic base64(userid:password)
生成编码后的认证信息,即上图中的Authorization
字段。下面列一个实际报文请求头加深理解:
GET /GetDeviceInfo HTTP/1.1
Host: 192.168.220.128
Authorization: Basic YWRtaW46MTIzNDU2
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:60.0) Gecko/20100101 Firefox/60.0
Accept-Encoding: gzip, deflate
Accept: */*
Cache-Control: no-cache
Cookie: Secure
Connection: close
此方法比较简单,但是安全缺陷很明显,它通过明文传输用户的密码,就很有可能被中间人窃取。因此如果可能,不要使用BASIC认证。
总结下BASIC
认证的方式和缺点:
- 将“用户名:密码”打包并采用Base-64编码。(提示:base64是可以直接解码的)
- 缺点:密码很容易被窥探,可以挟持编码后的用户名、密码信息,然后发给服务器进行认证;可以与
SSL
配合,隐藏用户名密码。
DIGEST认证
为弥补 BASIC
认证存在的弱点,从 HTTP/1.1
起就有了 DIGEST
认证。
DIGEST
认证同样使用质询/响应的方式,但不会像 BASIC
认证那样直接发送明文密码。
认证流程如下图所示:
这个过程比上一个要复杂一点,密码也不会传输了,显得安全了佷多,拿一个实际的请求头:
GET /GetDeviceInfo HTTP/1.1
Host: 192.168.220.128
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:60.0) Gecko/20100101 Firefox/60.0
Authorization: Digest username="admin",realm="TVT API Test Tool",nonce="d4f95e85dc5a39a4914db61b67878f5b",uri="GetDeviceInfo",algorithm="MD5",cnonce="d4f95e85dc5a39a4914db61b67878f5b",nc=00000001,qop="auth",response="1cc4cf126d3c4a70d2de34c5d8c2943c"
Accept-Encoding: gzip, deflate
Accept: */*
Cache-Control: no-cache
Cookie: Secure
Connection: close
我们可以看到,Authorization
中有佷多参数,我们来看下含义:
username
: 用户名(网站定义)password
: 密码realm
: 服务器返回的realm,一般是域名method
: 请求的方法nonce
: 服务器发给客户端的随机的字符串nc(nonceCount)
:请求的次数,用于标记,计数,防止重放攻击cnonce(clinetNonce)
: 客户端发送给服务器的随机字符串qop
: 保护质量参数,一般是auth,或auth-int,这会影响摘要的算法uri
: 请求的uri(只是path)response
: 客户端根据算法算出的摘要值
DIGEST
认证实际上也很简单,主要就是对response
进行校验,客户端生成一个response
,服务端收到认证请求后,根据同样的信息也可以生成一个response
,两者进行对比,如果相等,则校验成功,不相等则校验失败。
这个response
是如何生成的呢?
在最开始的RFC 2069中规定response计算方法如下:
HA1 = MD5(username:realm:password)
HA2 = MD5(method:uri)
response = MD5(HA1:nonce:HA2)
随后的RFC 2617对计算方法进行了增强,规定计算方法如下(当algorithm值为MD5或未指定、qop未指定时等同RFC 2069):
# HA1部分
# 当algorithm值为"MD5"或未指定时,HA1计算方法如下
HA1 = MD5(username:realm:password)
# 当algorithm值为"MD5-sess"时,HA1计算方法如下
HA1 = MD5(MD5(username:realm:password):nonce:cnonce)
# HA2部分
# 当qop值为"auth"或未指定时,HA2计算方法如下
HA2 = MD5(method:uri)
# 当qop值为"auth-int"时,HA2计算方法如下;entityBody是指整个body(?)
HA2 = MD5(method:uri:MD5(entityBody))
# response部分
# 当qop值为"auth"或"auth-int"时,response计算方法如下
response = MD5(HA1:nonce:nonceCount:cnonce:qop:HA2)
# 当qop未指定时,response计算方法如下
response = MD5(HA1:nonce:HA2)
所以最关键的一个值是nonce
,这个值是服务端生成的随机数,是要有一定时效性的。由于密码不会明文传输,相比BASIC
认证安全了佷多。
小小总结下:
- 不以明文发送密码,在上述第2步时服务器响应返回随机字符串
nonce
,而客户端发送响应摘要 =MD5(HA1:nonce:HA2)
,其中HA1=MD5(username:realm:password),HA2=MD5(method:digestURI)
- 在
HTTP
摘要认证中使用MD5
加密是为了达成"不可逆的",也就是说,当输出已知的时候,确定原始的输入应该是相当困难的。 - 如果密码本身太过简单,也许可以通过尝试所有可能的输入来找到对应的输出(穷举攻击),甚至可以通过字典或者适当的查找表加快查找速度。
- 缺点:
DIGEST
认证虽然提高了密码传输的安全性,但是无法杜绝客户端伪装的问题,因此还是相对较弱,达不到某些网站对高安全的要求。
SSL客户端认证
SSL 客户端是借由 HTTPS 的客户端证书完成认证的方式。凭借客户端证书认证,服务器可确认访问是否来自己登录的客户端。
这一块涉及HTTPS,后续我们来好好研究研究,这里先一带而过。
基于表单的认证
首先,这是最主流的方式,主要是靠代码来处理认证流程,前面两个基本不怎么用了。
基于表单的认证方法并不是 HTTP 协议中定义的 。客户端会向服务器上的 Web 应用程序发送登陆信息,按登陆信息的验证结果认证。根据 Web 应用程序的实际安装,提供的用户界面及认证方式也各不相同。多数情况下,输入用户名 ID 和密码等登陆信息后,发送给 Web 应用程序,基于认证结果来决定认证是否成功。
基于表单认证本身是通过服务器端的 Web 应用,将客户端发送过来的用户 ID 和密码进行校验。但鉴于 HTTP 是无状态协议,之前已认证成功的用户状态无法通过协议层面保存下来。即无法实现状态管理,因此即使当该用户下一次继续访问,也无法区分他与其他的用户。于是我们会使用 上一篇文章中介绍的 Cookie 和 Session,以弥补 HTTP 协议中不存在的状态管理功能。
步骤其实很简单,做过JAVA表单提交的同学随手都能写一个:
<form action= "Application/login" method = "POST">
用户名:<input id="username" name="username" type="text"/>
密码:<input id="password" name="password" type="password" />
<button type="submit">登陆</button>
</form>
不过我们都知道,这个玩意啥都不处理的话,就是明文在网络上传输,一遇到有心人就会被盗取。比如yummyfood商城,对login过程进行抓包得到此报文:
红色部分是请求报文,可以看到请求体中有一个:
{"username":"fossi","password":"123456"}
可恶,这么好听的名字和这么强的密码,一想到会被截取就很难受。那么有什么好办法吗?这里的学问还是挺多的,这篇文章讲的比较好,我这里贴一下:https://www.cnblogs.com/letcafe/archive/2018/11/14/loginSecurity.html
核心上来说,数据要避免被截取到,也就是说要对数据做加密,HTTPS还是要安排上。有同学会说,我不能前端做加密吗?
如果你是直接前端做密码MD5之类的加密,传到后端直接存储的话,那相当于没有加密,这一点大家都清楚。可以加上随机数做的稍微复杂点,比如:
每次请求均附加一个随机数(一般取时间戳),然后把用户输入的密码进行md5加密,加密后的值与随机数拼接,再取md5值,具体算法如下:Key = Md5(Md5(password)+ts);然后登录接口接收三个参数:用户名,随机值(时间戳),加密后的Key,登录逻辑按照加密方式进行判断。
用了这个方法,我的密码至少不会直接裸奔在网络上,但是这个方法好像还是不能防止黑客的暴力破解。如何防止这种情况呢?根据上面贴的文章得到启发,页面的验证码或token令牌必须要有一个了,每次打开登录页面的时候,从后端生成验证码或者token令牌,登录的时候传给后端进行校验,因此首先你客户端得传这个参数,第二你得传对,第三才会校验用户名密码是否正确,避免了黑客暴力登录。
不过解决了这种暴力破解问题,还有一个最重要的问题就是防篡改问题,这个就涉及数字签名技术,对传输的内容生成摘要,并且加密,接收方按照同样的算法和密码进行摘要的生成和加密,对比两者从而判断出内容是否被篡改。
HTTPS解决的问题主要就是三个:
- 窃听风险(
eavesdropping
):第三方可以获知通信内容。 - 篡改风险(
tampering
):第三方可以修改通信内容。 - 冒充风险(
pretending
):第三方可以冒充他人身份参与通信。
如何解决呢?
- 所有信息都是加密传播,第三方无法窃听。
- 具有校验机制,一旦被篡改,通信双方会立刻发现。
- 配备身份证书,防止身份被冒充。
即使使用了HTTPS,我们也不应该降低警惕性,密码该加密还是要加密,双重保障密码安全性,而不是仅仅依赖协议本身。验证码或TOKEN也得做,防止暴力破解。
不过也可以看到HTTPS为什么要普及开的原因,内容篡改和冒充的风险在互联网中到处存在,HTTPS最直接保护的是消费者。我们也没有理由不研究下HTTPS啦!暂且先不展开说明。