目录
HTTP协议
HTTP(Hyper Text Transfer Protocol)协议又叫做超文本传输协议,是一个简单的请求-响应协议。
虽然应用层协议是程序员自己定的,但是有些大佬已经定义了一些非常好用的应用层协议,我们直接参考使用就可以了。
认识URL
URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,它用来标识网络中唯一的一个资源。
一个URL大致由这些部分构成:
服务器地址:
这里的域名就是IP地址,需要进行域名解析才可以得到IP地址。
可以看到域名IP后面应该是端口号,但是这里并没有写端口号,因为一般我们请求的网络服务的端口号都是众所周知的,也就是所有的浏览器都知道的,所以端口号是可以忽略的,http默认绑定的端口就是80,后面说的https绑定的就是443端口。
文件路径:
域名IP后面没有端口号的话,‘/’后面的第一个叫做Web根目录,我们平时上网会有两种操作,一是获取,二是上传。网络上的服务器中就存在着大量的资源,其实就是大量的文件,当我们想要获取这些资源就要让服务端打开要获取的文件,读取文件,再通过网络发送回来。所以我们要找到这个文件要通过路径。
查询字符串:
我们使用百度搜索的时候,再URL中也会出现这样的参数wd=_。
下面的Linux可以正常的显示出来,但是C++却显示的是C%2B%2B,这是因为2B是以十六进制显示的,转换为10进制就是43,对应的ASCII码就是+,前面再加上%。
汉字也要做特殊处理的。
urlencode和urldecode
像 / ? : 等这样的字符已经被url当做特殊意义理解了。因此这些字符不能随意出现。比如,某个参数中需要带有这些特殊字符,就必须先对特殊字符进行转义。
将需要转码的字符转为十六进制,然后从右到左,取4位(不足4位直接处理),每两位做一位,前面加上%,编码成%XY格式,这就是为什么搜索C++的时候会出现C%2B%2B。
这样的工具如果想要自己实现,网上也有很多,这里就不过多赘述了。
HTTP协议格式
HTTP基于请求和响应,这种模式就是CS模式,而他们交互的就是双方的报文。HTTP是应用层的协议,底层采用的叫做TCP,在通信之前一定已经进行了三次握手的过程,具体三次握手是什么,我们到TCP的章节会说的。
在报文的角度,HTTP是基于行的文本协议。
HTTP请求协议
格式如下:
在请求的时候的http版本是client告诉server,client用的是哪个版本。
其中,前三部分的请求行、请求报头、空行,一般是HTTP协议自带的,是由HTTP协议自行设置的,而请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,此时请求正文就为空字符串。
当服务器收到一个HTTP请求的时候,必须要将HTTP的报头和有效载荷进行分离,对于请求来讲,请求行和请求报头就是HTTP的报头,我们就是根据请求中的空行来进行分离的,我们收到请求后按行读取,如果读到空行就说明已经将报头读取完毕。
虽然读到空行,但是我怎么知道后面的正文有多少呢?所以报头中就会有一个属性就是:Cotent-Length : 正文长度。这就知道了后面的正文是多少字节。
我们还是实现一个简单的TCP服务器,然后我们通过浏览器发送请求到服务器,看看请求报文是什么样的。
#include <iostream> #include <functional> #include <signal.h> #include "Sock.hpp" class HttpServer { public: typedef std::function<void(int)> func_t; HttpServer(const uint16_t& port, func_t func) :port_(port) ,func_(func) { listensock_ = sock_.Socket(); sock_.Bind(listensock_, port_); sock_.Listen(listensock_); } void Start() { signal(SIGCHLD, SIG_IGN); while (true) { std::string clientIp; uint16_t clientport; int sockfd = sock_.Accept(listensock_, &clientIp, &clientport); if (sockfd < 0) continue; if (fork() == 0) { close(listensock_); func_(sockfd); exit(0); } close(sockfd); } } ~HttpServer() { if (listensock_ >= 0) close(listensock_); } private: int listensock_; uint16_t port_; Sock sock_; func_t func_; };
都是我们原来写过的代码,也就不过多赘述了,这次我们使用创建子进程的方法,为了避免出现僵尸进程,我们先忽略SIGCHLD信号。
void HandlerHttpRequest(int sock) { char buffer[10240]; ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); if (s > 0) { buffer[s] = 0; std::cout << buffer << "-----------------------\n" << std::endl; } } int main(int argc, char* argv[]) { if (argc != 2) { Usage(argv[0]); exit(1); } std::unique_ptr<HttpServer> httpserver(new HttpServer(atoi(argv[1]), HandlerHttpRequest)); httpserver->Start(); return 0; }
我们把显示报文的方法传入,这样我们就可以获得请求的报文了,在浏览器的上方url搜索框输入:云服务器的IP:port就可以发送请求了。
- 浏览器向我们的服务器发起HTTP请求后,我们的服务器并没有响应,此时浏览器就会认为服务器没有收到,然后再不断发起新的HTTP请求,因此虽然我们只用浏览器访问了一次,但会受到多次HTTP请求,这里我只截取的一段。
- 浏览器发起请求时默认用的就是HTTP协议,可以不用指明http://。
- 报头中的/不能称之为云服务器上根目录,/表示的是web根目录,这个web根目录可以是机器上的任何一个目录,这个是可以自己指定的,不一定就是Linux的根目录。
HTTP响应协议
格式如下:
在响应的时候的http版本是server告诉client,server用的是哪个版本。
HTTP响应报文中的状态行、响应报头和有效载荷的分离与请求报文的处理方式是一样的,客户端收到响应后就可以按行读取,读到空行说明报头已经读取完毕,下面就是响应正文。
我们模拟实现一个响应报文。
void HandlerHttpRequest(int sock) { // 1. 读取请求 char buffer[10240]; ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); if (s > 0) { buffer[s] = 0; std::cout << buffer << "-----------------------\n" << std::endl; } // 2. 模拟响应 std::string HttpResponse = "HTTP/1.1 200 OK\r\n"; // 状态码200表示成功,描述就是OK HttpResponse += "\r\n"; HttpResponse += "<html><h3>Hello Http</h3></html>"; send(sock, HttpResponse.c_str(), HttpResponse.size(), 0); }
使用telnet也可以请求和响应。
HTTP协议细节
当我们在浏览器的url搜索框中输入ip和端口号后,也可以添加Web根目录。在我们获取请求的时候,我们可以对请求这个字符串做一下截取,因为有可能发过来的是/,或者是/index.html这样的,所以要进行一些处理。
class Util { public: static void cutString(const std::string &s, const std::string &sep, std::vector<std::string> *out) { std::size_t start = 0; while (start < s.size()) { auto pos = s.find(sep, start); if (pos == std::string::npos) break; std::string sub = s.substr(start, pos - start); std::cout << "----" << sub << std::endl; out->push_back(sub); start += sub.size() + sep.size(); } } }; void HandlerHttpRequest(int sock) { // 1. 读取请求 char buffer[10240]; ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); if (s > 0) { buffer[s] = 0; std::cout << buffer << "-----------------------\n" << std::endl; } std::vector<std::string> vline; Util::cutString(buffer, "\r\n", &v); }
前面就是请求的报文,后面就是截取的报文,并把他们放到了vector中。但是还没有完,我们想要找到请求报文中的Web根目录,虽然拿到了行,然后还要截取请求行中的每一块,这样才能拿到请求的Web根目录,还要再改一改我们写代码。
class Util { public: static void cutString(const std::string &s, const std::string &sep, std::vector<std::string> *out) { std::size_t start = 0; while (start < s.size()) { auto pos = s.find(sep, start); if (pos == std::string::npos) break; std::string sub = s.substr(start, pos - start); out->push_back(sub); start += sub.size() + sep.size(); } if (start < s.size()) out->push_back(s.substr(start)); } }; void HandlerHttpRequest(int sock) { // 1. 读取请求 char buffer[10240]; ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); if (s > 0) { buffer[s] = 0; std::cout << buffer << "-----------------------\n" << std::endl; } std::vector<std::string> vline; Util::cutString(buffer, "\r\n", &vline); std::vector<std::string> vblock; Util::cutString(vline[0], " ", &vblock); for (auto& e : vblock) { std::cout << e << std::endl; } }
这样我们就拿到了请求头中的资源路径,就是vblock[1]。
#define ROOT "./wwwroot" // 我们自己的Web根目录 #define HOMEPAGE "/index.html" void HandlerHttpRequest(int sock) { // 1. 读取请求 char buffer[10240]; ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); if (s > 0) { buffer[s] = 0; } std::vector<std::string> vline; Util::cutString(buffer, "\r\n", &vline); // 截取报头,把缓冲区传入,按照\r\n这样的格式切分,放到vector中 std::vector<std::string> vblock; Util::cutString(vline[0], " ", &vblock); // 拿到要获取的资源路径 std::string file = vblock[1]; std::string target = ROOT; // 拼接资源路径 if (file == "/") file = HOMEPAGE; target += file; std::cout << target << std::endl; // C++文件操作 std::string connect; // 缓冲区 std::ifstream in(target); // 打开文件 if (in.is_open()) // 判断是否打开 { std::string line; while (std::getline(in, line)) // 按行读取html文件 { connect += line; } in.close(); // 关闭文件 } std::string HttpResponse; // 响应报文 if (connect.empty()) HttpResponse = "HTTP/1.1 404 NotFound\r\n"; // 是否读取到了响应正文,按需添加状态行 else HttpResponse = "HTTP/1.1 200 OK\r\n"; HttpResponse += "\r\n"; // 添加空行 HttpResponse += connect; // 添加正文 send(sock, HttpResponse.c_str(), HttpResponse.size(), 0); // 发送 }
如果拿到的是“/”就代表访问的时候没有传入资源路径,那么直接返回默认的路径下的文件,如果有请求的路径,那就找到响应的资源再响应回客户端。
HTTP请求方法
我们上网行为无非就两种,从服务端拿数据,把数据上传到服务端。
我们最常用的就是GET和POST方法,GET就是向服务端发起请求,POST就是向服务器递交数据,其他的方法要么不支持,要么就是服务端不想让客户使用,常用的还是这两个。 我们可以使用telnet向百度的服务器请求资源,使用GET / HTTP/1.1,就是一个简单的请求行,然后就是空行,没有请求正文就直接换行就拿到了百度服务器给我们响应的报文,报文后还有百度根目录的默认页面的html文件。
方法 说明 支持的HTTP协议版本 GET 获取资源 1.0、1.1 POST 传输实体主体 1.0、1.1 PUT 传输文件 1.0、1.1 HEAD 获得报文首部 1.0、1.1 DELETE 删除文件 1.0、1.1 OPTIONS 询问支持的方法 1.1 TRACE 追踪路径 1.1 CONNECT 要求用隧道协议连接代理 1.1 LINK 建立和资源之间的联系 1.0 UNLINK 断开连接关系 1.0
GET和POST的区别
首先我们先写一个简单的html文件。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <form name="input" action="index.html" method="get"> Username: <input type="text" name="user"><br/> Password : <input type="password" name="user"><br/> <input type="submit" value="Submit"> </form> </body> </html>
先不用看其他的地方,先看form创建一个表单,这里使用get方法。
当我们提交后,服务器就会获得一个GET请求,同时这里的URL也发生了变化。
我们截取到的url也变成了./wwwroot/index.html?user=123&pwd=123,但是我们此时的根目录下并没有这个服务或者是文件,所以就请求不到。
所以GET方法是通过url向服务端传参的。
再然后,我们把表单中的method设置为post。
可以看到,请求方法变成了post,并且把提交的数据放到了正文部分,所以POST方法是通过http的正文提交参数的,所以不会影响url,这个url就是表单中action后跟的文件。
所以说,我们是先向服务器申请资源,申请这个表单,在通过GET或者POST方法把数据上传到服务器。
- GET方法通过url传参,并把输入的信息显示到url中,这种方法就不够私密。
- POST方法通过正文提交参数,不会显示出来,一般的私密性是有保证的。
- 但是这两种方式都不是安全的,对数据加密和解密才是安全的。
HTTP状态码
HTTP的状态码:我们在分别看看他们是什么意思。
状态码 类别 原因 1XX Informational(信息性状态码) 接收的请求正在处理 2XX Success(成功状态码) 请求正常处理完毕 3XX Redirection(重定向状态码) 需要进行附加操作以完成请求 4XX Client Error(客户端错误状态码) 服务器无法处理请求 5XX Server Error(服务器错误状态码) 服务器处理请求出错
- 以1开头的状态码,服务器表示客户端的请求已经收到了,可能比较耗时,但是我们使用时并不会请求太大的资源。
- 以2开头的状态码,这就表示请求成功,典型的就是200,请求的内容已经放在正文部分了。
- 以3开头的状态码,通过重定向去跳转。
- 以4开头的状态码,常见的就是404,描述就是NotFound,这个网页不存在,客户端请求的非法资源服务器无法给你。
- 以5开头的状态码,服务器中的错误,比如创建进程或者子进程失败等,但是服务通常不会让用户看到。
我们再来看看重定向状态码。通过某种方法将请求重定向到其他位置。
重定向又可分为临时重定向和永久重定向,其中301表示永久重定向,302和307表示的是临时重定向。
临时重定向和永久重定向本质是影响客户端访问的位置。
如果某个网站是永久重定向,在第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时你访问的直接就是重定向后的网站。
如果某个网站是临时重定向,在访问该网站时如果需要进行重定向,都需要浏览器来帮我们完成重定向跳转到目标网站。
void HandlerHttpRequest(int sock) { // 1. 读取请求 // ... // C++文件操作 // ... std::string HttpResponse; if (connect.empty()) { HttpResponse = "HTTP/1.1 302 Found\r\n"; HttpResponse += "Location:https://www.csdn.net/"; } else HttpResponse = "HTTP/1.1 200 OK\r\n"; HttpResponse += "\r\n"; HttpResponse += connect; send(sock, HttpResponse.c_str(), HttpResponse.size(), 0); }
这里我们就故意写一个不存在的资源,当connect中没有读到数据,就直接重定向到csdn的首页。
HTTP常见的Header
我们再来看一下HTTP常见的报头:
- Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
- Connection:连接类型,分为长连接和短连接。
- Content-Length:正文的长度。
- Content-Type:数据类型(text/html等)。
- User-Agent:声明用户的操作系统和浏览器的版本信息。
- Accept:可接收的资源类型
- Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
- Referer:当前页面是哪个页面跳转过来的。
- Cookie:用于在客户端存储少量信息,通常用于实现会话(session)的功能。
这里的报文虽然不全,但是可以看出对应的信息,像Location在上面的重定向中就已经使用过了。
还有报文中的Connection,后面是keep-alive就是长连接,是close就是短连接。像一些大的网站会有非常多的资源,这就采用的长连接,在一次连接中就可以把所有资源全部拿到,不像我们使用的短连接,如果资源太多的话,每次都要创建进程等,TCP就要多次连接,成本还是很高的。如果资源太多的话,还是使用长连接效率会更高。
报头中还有一个Cookie,我们再来看看他是什么。
Cookie与Session
说到了这里我们也知道了http有哪些特征:
- http简单,响应速度快。
- http无连接,是传输层的TCP维护的连接。
- http无状态,不管用户是否请求过,不会有任何记录。
但是实际上,我们使用的时候,这个网站是要记录我的用户的,比如我登录一个网页,即使我把浏览器关了,下次再打开的时候依旧是已登录的状态。
当我们登录的时候会把信息提交到服务端,服务端就会进行认证,认证通过后在浏览器的目录中就会有一个文件或者一个内存文件保存这些信息,所以以后的请求都会携带这些信息,我们把这样的文件就叫做Cookie文件。
当我们把网站的Cookie文件删除后就没有已登录的显示了。
但是如果我的客户端机器被植入了恶意的软件,这个软件会读取我浏览器下的Cookie文件,拿着这个Cookie文件访问网站的话,那我的信息就被泄露了,所以不建议使用这种方法,但是还要使用Cookie文件。
在服务器的认证过程中,我们一开始要注册,注册成功后,在服务端会通过算法形成一个唯一ID,并在服务端创建一个文件保存私密信息,命名就使用唯一ID,这个唯一ID就叫做Session id。
然后服务端就返回这个session id等信息,保存到客户端本地的Cookie文件中,当客户端请求的时候携带的就是session id,再进行服务端认证,认证通过就可以返回请求的资源。
但是,那个恶意软件又来了,继续盗取的我的Cookie文件下的session id,那同样可以访问对应的网站啊,但有一点要注意,虽然Cookie文件泄漏了,但是我的私密信息是看不到的,因为信息放在了服务端,而且不能避免Cookie文件被盗取。
想要解决就需要通过服务端的一些设置了,比如可以通过识别ip地址,如果用户的ip地址出现异常,那么就在服务端设置你的session id失效了,并通知你这个用户,要么重新登录,要么修改密码等操作。
说了这么多,那我们就自己设置一下Cookie文件吧.
void HandlerHttpRequest(int sock) { // ... std::string HttpResponse; if (connect.empty()) { HttpResponse = "HTTP/1.1 302 Found\r\n"; HttpResponse += "Location: https://www.csdn.net/"; } else { HttpResponse = "HTTP/1.1 200 OK\r\n"; HttpResponse += ("Content-Type: text/html\r\n"); HttpResponse += ("Content-Length: " + std::to_string(connect.size()) + "\r\n"); HttpResponse += "Set-Cookie: 这是我设置的Cookie\r\n"; } HttpResponse += "\r\n"; HttpResponse += connect; send(sock, HttpResponse.c_str(), HttpResponse.size(), 0); }
当我们点击提交后,服务端就收到了这样的报文。
HTTPS协议
在一开始使用的应用层协议都是HTTP,而HTTP无论是用GET方法还是POST方法传参,都是没有经过任何加密的,所以很多的信息都是可以通过抓包工具抓到,如果拿到了我的账户和密码,那么我的信息就被泄漏了。
但是现在我们还要学习HTTP协议,主要是因为如果在公司中,已经确定内网环境很安全,还是使用HTTP协议,因为它做的事情更少,效率更高;还有一个原因,如果我们以后想自己定制协议,就可以仿照HTTP。
GET和POST方法的不安全前面已经说过了,GET会显示在URL中,POST会显示在正文中,这就做不到真正的安全,想要安全还是要对数据进行加密和解密。
这里的安全也不是我们平常说的安全,但是可以理解为:破解的成本远远大于破解后的收益。而成本和收益也可以是时间或者金钱等等。
那HTTPS是什么呢,HTTP是一个应用层协议,在HTTP协议的基础上引入一个加密软件层这就是HTTPS。
通信双方使用的应用层协议必须是一样的,如果两端使用的都是HTTPS,加密后直到数据传入对端的应用层,这中间都是加密的。
加密和解密
- 加密:加密就是把明文或者说是要传输的数据经过一系列变换,生成密文。
- 解密:解密就是把密文还原成明文。
在这个过程中需要一个或多个中间值来完成这些操作,这种数据就称之为密钥。
对称加密
采用单钥密码系统,同一个密钥可以加密也可以解密,这就叫做对称加密,也叫做单密钥加密。
它的特点就是:算法公开、计算量小,加密速度快,加密效率高。
简单理解对称密钥,可以举个例子,就是异或运算“^”。
当然只是举个例子,实际上要比这复杂的多。
非对称加密
采用两个密钥进行加密和解密,这两个密钥就是“公钥”和“私钥”。
采用非对称加密算法强度复杂,安全性依赖于算法,因为复杂,所以加密和解密的速度没有对称加密的方式快。
而且公钥和私钥是要配对使用的,使用方法就是:
- 通过公钥对明文加密,变成密文
- 通过私钥对密文解密,变成明文
或者反着使用:
- 通过私钥对明文加密,变成密文
- 通过公钥对密文解密,变成明文
1、只使用对称加密
如果,双方都各自持有同一个密钥,没有任何人知道,这就可以保证双方的通信是安全的,只要没有人破解。
尽管其中有黑客攻击,那他也拿不到密钥,所以没办法破解,但是这就产生了一个新问题,服务端怎么把这个密钥传递给客户端呢,是不是得通过网络传输,那第一次就需要传入密钥,如果此时被截取,那不就不安全了吗,而且每个客户都要有单独的密钥,如果相同还是有被破解的风险,而服务器还要维护这么多密钥,那也是有成本的。
所以密钥的传输也要加密,那这不就是先有鸡还是现有蛋的问题了吗,所以这种方法不可行。
2、只使用非对称加密
开始客户端向服务端发起请求,服务端把公钥返回,所有客户端都知道了这个公钥,客户端使用公钥加密后,只有私钥才能解密,所以保证了从客户端到服务端的安全。
但是,服务端向客户端发送消息的时候,只能使用私钥加密,使用公钥解密,但是全网都知道了这个公钥,所以不能保证服务端发送数据到客户端的安全,所以这种方式也不可行。
3、双方都是用非对称加密
双方都产生一个公钥和私钥,前两次双方把公钥公开交换,所以其他人也可以拿到双方的公钥,此时客户端拿服务端的公钥加密数据,服务端拿自己的私钥解密;服务端拿客户端公钥加密,客户端拿自己的私钥解密。
这个流程看起来还是安全的,但是上面说过使用非对称加密算法强度高,速度没有对称加密快,效率非常低。
4、非对称加密+对称加密
前面的密钥协商阶段,通过非对称加密,将客户端本地的对称密钥加密,从而使双方都拿到了对称密钥,以后双方都使用对称密钥来通信。
中间人攻击
Man-In-The-MiddleAttack,简称“MITM攻击”。
第一种我们就不说了;第二种客户端向服务端请求的过程可以是安全的,但是服务端响应的数据就不是安全的;第三种倒是可以,就是速度慢,效率低;第四种也是比较好的策略,较少的使用非对称加密,可以提高速度和效率。但是上面的方案还是有安全问题的。
所以,以上的方案都是有安全问题,但是我们发现,只要客户端和服务端交换了密钥,中间人就不能攻击;而在密钥协商阶段获取了密钥就会出现问题,那么就衍生出了第五种方案。
5、非对称加密+对称加密+CA认证
首先我们要知道的是,中间人能够攻击成功的原因:
- 中间人能够对数据做修改。
- 客户端无法验证收到的公钥是否是服务器发过来的。
围绕着这两条原因,我们就要对症下药。
- 首先客户端需要对服务器的合法性进行认证。
如何理解认证,我们可以举个例子,有时候我们可能会在高速公路口遇到公安检查,这时候我们就要拿出身份证,这要这个身份证和我是对应上的,那我就可以正常通过,所以,警察是相信身份证的,因为这个身份证是政府这个权威机构颁发的。
所以在互联网中就有这样一个权威机构就是CA机构,他可以对服务器进行认证,认证成功后就会给服务端颁发CA证书。
在服务端向客户端发送的其实是证书,在这个证书之中就有信息,包括CA机构、有效时间、域名和公钥等,所以我们先来说一下CA机构是如何认证的。
如果在服务端向客户端响应CA证书时,中间人修改了证书中明文信息的公钥,那么客户端在使用hash函数形成的数据摘要,证书中的数字签名通过公钥A解密形成数据摘要,这两个数据摘要一对比就会失败,客户端意思到证书被修改了,直接断开和服务端的连接。
如果中间人想要造一个假的CA证书呢,那也是做不到的,原因就是中间人没有CA的私钥,无法形成数字签名。那只能造一个真证书,但是如果把服务端的证书和中间人的证书掉包,客户端是知道自己访问的是哪个网站的,与证书中的网站不一样也是可以识别出来的,还是因为中间人没有CA机构的私钥。