详解HTTP/HTTPS协议
HTTP
HTTP协议全名为超文本传输协议。HTTP协议是应用层协议,其传输层协议采用TCP协议。
请求—响应模型
HTTP协议采用请求-响应模型,通常由客户端发起请求由服务端完成响应。资源存储在服务端,客户端通过请求服务端获取资源。
认识URL
当我们访问网页时,浏览器扮演的就是客户端的角色。那么客户端如何请求服务器呢?
在浏览器中我们如果要访问网页,我们首先需要知道网页的地址即网址。而我们所说的网址就是URL。
一个典型的URL由以下几个部分组成:
- 协议:指定获取资源所使用的协议,如 http、https、ftp 等。
- 用户名和密码(可选):如果需要认证,可以包含用户名和密码,格式为 username:password@。
- 主机名:资源所在的服务器域名或IP地址。
- 端口号(可选):服务器监听的端口号,默认情况下 http 为80端口,https 为443端口。
- 路径(Path):服务器上资源的路径,以/开始。
- 查询字符串(可选):用于传递参数,以?开始,参数之间用&分隔,如 ?key1=value1&key2=value2。
- 片段标识符(可选):指向资源内部的特定部分,如页面中的某个章节,格式为 #section。
实际上一个URL必须要有的只有三个:协议,主机IP,资源路径。但实际上我们访问一个网站时只需要在浏览器地址栏输入域名即可。浏览器默认会使用 http 协议尝试连接。如果 http 连接失败,大多数现代浏览器会自动尝试使用 https 协议重新连接。如果两种协议都失败了,浏览器会显示错误信息。
域名会绑定一个IP地址,浏览器会解析域名得到服务端的IP地址,而资源路径在没有指定的情况下,浏览器默认指定为 / 。
urlencode和urldecode
像 / ? : 等这样的字符,已经被url当做特殊意义理解了。 因此这些字符不能随意出现。比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义。
转义的规则如下: 将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY 格式。
我们使用腾讯新闻简单实验一下:
我们在搜索 qq 关键字,在 / 后是我们访问的资源路径。在 ?是我们提交的参数,以 & 分割。query 是我们搜索的关键字 qq 。我们换成几个空格尝试搜索一下。
可以看到空格被替换成了 %20。urlencode就是转义过程,urldecode就是解码过程。
HTTP请求格式
我们先来介绍HTTP协议的请求格式。HTTP协议的请求由四部分组成。
请求行,请求头,空行,请求正文。 不同部分之间使用 \r\n 分隔。
请求行
请求行由三个结构组成:请求方法+URL+HTTP协议版本号。
HTTP的请求方法由以下几种:
其中最常用的就是GET方法和POST方法。
- GET方法主要用于请求资源,请求从服务器检索特定资源。
- POST方法主要用于向服务器提交数据进行处理,通常用于发送数据。
请求头
HTTP请求头包含了客户端向服务器发送HTTP请求时提供的数据。包含了请求的属性,数据使用冒号分割的键值对存储,每组属性之间使用\n分隔。遇到空行表示请求头结束。
- Content-Type:数据类型(text/html等)。
- Content-Length: 请求正文Body的长度。
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上。
- User-Agent: 声明用户的操作系统和浏览器版本信息。
- referer: 当前页面是从哪个页面跳转过来的。
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问。
- Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能。
空行
空行为 /r/n ,无实际含义。用于分隔请求头与请求正文。
请求正文
请求正文非必须项,一个请求可以携带请求正文也可以不携带的。空行后面的内容都是请求正文,如果请求正文存在, 则在请求头中会有一个Content-Length属性来标识Body的长度。
抓取请求
HTTP 协议是基于 TCP 协议的,我们可以编写一个简单的服务端接受浏览器的请求并打印。
http_server.hpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <string>
#include <string.h>
#include <iostream>
#include <pthread.h>
#include <sys/types.h>
#include <sys/uio.h>
#include <unistd.h>
using namespace std;
class server
{
public:
server(uint16_t port=80,string ip="0.0.0.0")
:_port(port)
,_ip(ip)
{}
static void* handler(void* arg)
{
char buffer[1024];
ssize_t size=read(*(int*)arg,buffer,sizeof(buffer)-1);
std::cout << buffer;
}
bool init()
{
//创建套接字
_listen_fd=socket(AF_INET,SOCK_STREAM,0);
if(_listen_fd<0)
{
cout << "socket false" << endl;
return false;
}
cout << "socket true" << endl;
//套接字绑定
memset(&_local,'\0',sizeof(sockaddr_in));
_local.sin_family=AF_INET;
_local.sin_port=htons(_port);
_local.sin_addr.s_addr=inet_addr(_ip.c_str());
if(bind(_listen_fd,(struct sockaddr*)&_local,sizeof(struct sockaddr_in)) < 0)
{
std::cout << "bind false" << endl;
return false;
}
std::cout << "bind true" << endl;
if(listen(_listen_fd,5)<0)
{
std::cout << "listen false" << endl;
return false;
}
std::cout << "listen true" << endl;
std::cout << endl;
return true;
}
void start()
{
while(1)
{
sockaddr_in send_to;
socklen_t len=sizeof(send_to);
int sock = accept(_listen_fd,(sockaddr *)&send_to,&len);
if(sock<0)
{
//cout << "accept error" << endl;
continue;
}
pthread_t thread_id;
int* num = new int(sock);
pthread_create(&thread_id, NULL,handler,(void*)num);
pthread_detach(thread_id);
}
}
private:
uint16_t _port;
string _ip;
sockaddr_in _local;
int _listen_fd;
};
main.cpp
#include "http_server.hpp"
int main()
{
server ser;
ser.init();
ser.start();
return 0;
}
我们可以在浏览器访问主机,直接在地址框输入主机IP地址即可。
因为我们没有响应,所以无法访问,但我们可以查看浏览器发送的 HTTP 请求。
浏览器请求了两次,两次均没有请求正文。
第一行为请求行,采用GET方法,访问 /cgi-bin/luci/;stok=/locale 目录,HTTP/1.1 表示采用HTTP协议 1.1 版本。
下面为请求头
- Host: 39.96.176.87:80 指定请求的目标服务器的IP地址和端口号(80)。
- User-Agent: Go-http-client/1.1 表示发起请求的客户端是一个使用Go语言编写的HTTP客户端,版本为1.1。
第二次请求同样采用 GET 方法,请求资源路径为 / 。同样为 1.1 版本。对于请求头感兴趣的读者可以自行研究。
HTTP响应格式
HTTP协议的响应同样由四部分组成。
状态行
状态行由三个部分组成 :版本号 + 状态码 + 状态码解释。
状态码主要有以下几种:
最常见的状态码,:200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway) 。
响应头
响应头是服务器在响应客户端请求时发送的一系列键值对信息,它们提供了关于响应的数据,包括服务器信息、缓存控制、内容类型、安全策略等。以下是一些常见的HTTP响应头及其含义。
Content-Type:指定返回的内容的MIME类型,例如
text/html
、application/json
、image/png
等。Content-Length:表示响应体的字节数,用于告知客户端响应体的大小。
Cache-Control:控制响应的缓存行为,例如
no-cache
、no-store
、max-age=3600
等。Expires:指定资源到期的时间,过期后客户端需要重新请求资源。
Last-Modified:资源上次修改的时间,用于缓存验证。
ETag:资源的特定版本的标识符,用于缓存验证。
Server:提供了服务器软件的信息,例如
Apache/2.4.1 (Unix)
。Set-Cookie:用于设置客户端的cookie,用于会话管理和用户跟踪。
Location:指示客户端重定向到指定的URI。
WWW-Authenticate:提示客户端需要进行身份验证,通常与401 Unauthorized状态码一起使用。
Access-Control-Allow-Origin:用于跨源资源共享(CORS),指示哪些域名可以访问资源。
Strict-Transport-Security:指示客户端(浏览器)只通过HTTPS与服务器通信,增强安全性。
X-Frame-Options:用于防止点击劫持攻击,指示资源是否可以在frame、iframe或object中显示。
Content-Encoding:表示响应体内容的压缩格式,例如
gzip
、deflate
。Content-Language:表示响应体内容的语言,例如
en-US
、zh-CN
。Content-Security-Policy:定义了哪些动态资源是可信任的,用于防止跨站脚本攻击(XSS)。
X-Content-Type-Options:通常设置为
nosniff
,指示浏览器不要猜测响应内容的类型。X-XSS-Protection:启用特定浏览器的XSS过滤和阻断功能。
Date:表示响应发送的日期和时间。
Connection:控制持久连接,例如
keep-alive
或close
。
空行
空行为 /r/n ,无实际含义。用于分隔响应头与响应正文.
响应正文
HTTP响应正文,也称为响应体,是HTTP响应中包含的主体数据部分。它包含客户端请求的资源或信息,比如HTML文档、图片、视频、JSON数据等。响应体的内容和格式由请求的资源和Content-Type响应头字段决定。
- 内容类型:响应体的内容类型由Content-Type响应头指定,这告诉客户端正文中数据的类型。常见的内容类型包括text/html、application/json、image/jpeg等。
- 编码:响应体可能经过压缩,如gzip或deflate,这是通过Content-Encoding响应头指定的。客户端需要知道如何解压响应体。
- 长度:Content-Length响应头指示响应体的字节长度,这有助于客户端知道需要读取多少数据。
- 可读性:对于文本类型的响应体(如HTML、CSS、JavaScript、JSON等),客户端可以直接显示或解析这些数据。对于二进制数据(如图片、视频、PDF等),客户端需要以特定的方式处理和显示。
- 分块传输:对于大文件或流式传输的数据,响应体可能使用分块传输编码(chunked transfer encoding),这意味着数据是分块发送的,而不是一次性发送完整的响应体。
- 空响应体:某些HTTP响应可能不包含响应体,如204 No Content和304 Not Modified状态码的响应。
- 错误信息:对于错误状态码(如4xx和5xx),响应体通常包含错误信息,描述了发生错误的原因。
- 安全性:对于敏感数据,响应体可能被加密,以确保数据在传输过程中的安全。
- 多部分类型:在某些情况下,响应体可能包含多个部分,每个部分都有自己的Content-Type。这通过Content-Type: multipart/*响应头指示,并且每部分之间由特定的分隔符分隔。
- 字符集:对于文本数据,字符集通过Content-Type响应头中的charset参数指定,如charset=UTF-8,这告诉客户端如何正确解码文本数据。
完成响应
在收到 HTTP 请求后我们可以对请求进行解析,然后完成发送信息完成响应。
我们可以写一个简单的html网页,在对解析请求后完成响应。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
我们在handler函数中加入响应。
static void* handler(void* arg)
{
char buffer[1024];
ssize_t size=read(*(int*)arg,buffer,sizeof(buffer)-1);
std::cout << buffer;
string body;
ifstream file("index.html");
string s;
while (getline(file, s))
{
body += s + "\n";
}
//请求行
string line;
line="HTTP/1.1 200 OK";
string head;
int len=body.size();
head = "Content-Leng: " + to_string(len) + "\n";
head += "Content-Type: text/html\n";
string response;
response= line + "\r\n" + head + "\r\n" + "\r\n" + body;
cout << "——————————————————————————————————————————————————" << endl;
cout << response;
cout << "——————————————————————————————————————————————————" << endl;
write(*(int*)arg,response.c_str(),response.size());
}
因为我们传输的是 html 文件,所以我们需要在响应头指明 Content-Leng (响应正文长度)与 Content-Type (响应正文类型)。
下面我们再次访问服务器:
可以看到完成了响应。浏览器显示了 Hello World。
Cookie与Session
HTTP是无状态的协议,所谓无状态意味着服务器不会在不同的请求之间保存任何会话信息。每次HTTP请求都是独立的。由于不需要保存状态,服务器可以更高效地处理请求,有助于提高性能和可伸缩性。但由于其无状态的特点服务器无法识别用户,每次请求可能都需要重新验证用户身份。
为了解决这个问题,我们可以使用Cookie与Session技术。
cookie
假设一个用户登录到一个网站,无状态的HTTP协议不会在请求之间记住用户的登录状态。为了解决这个问题,我们可以通过设置 cookie 来保存登录信息。
当我们第一次登录时,我们输入用户名和密码,提交登录表单。有服务器验证,如果有效,服务器会再响应时通过 Set-Cookie 设置一个 Cookie 。浏览器接受响应后会提取 Set-Cookie 的值,保存在 Cookie 文件中。
在设置了 Cookie 后再发起的 HTTP 请求当中会自动包含 cookie 信息,此时就不需要重复验证。
Cookie 可以分为以下几种类型:
内存中的 Cookie(会话 Cookie)
- 存储位置:这种类型的 Cookie 存储在浏览器的内存中,而不是写入磁盘。
- 持久性:它们是临时的,只在浏览器会话期间存在。当用户关闭浏览器窗口或标签页时,这些 Cookie 会被自动删除。
- 用途:会话 Cookie 通常用于维护用户的会话状态,例如在用户浏览网站时保持登录状态。
磁盘上的 Cookie(持久 Cookie)
- 存储位置:这种类型的 Cookie 被写入到用户设备的文件系统中,通常是在浏览器配置的存储目录下。
- 持久性:它们可以设置一个过期时间,在这个时间之前,Cookie 会一直存储在用户的设备上,即使浏览器关闭也会保留。
- 用途:持久 Cookie 用于在用户再次访问网站时恢复某些信息,如个性化设置、购物车内容等。
SessionID
上面我们说Cookie会保存我们的登录信息,但如果Cookie直接存储的是账号密码或者其他隐私信息,一旦被盗取隐私信息就会泄露,所以Cookie中保存的实际上是 SessionID 。
SessionID 是由服务器生成的一串随机字符,它本身不包含任何敏感信息,用于在服务器端检索用户的实际会话数据。实际的用户信息和会话数据存储在服务器端,而SessionID 与这些数据关联,因此我们只需要存储 SessionID 就相当于存储了这些数据。
为什么引入Session更安全
虽然我们没有存储敏感信息,但我们存储了 SessionID ,在盗取 Cookie 后持有SessinID 同样可以以用户的身份访问服务器,但结合其他方法,我们可以实现相对安全,如检测到异地IP登录,立即清除服务器中保存的Session,要求重新登录,这时只有真正的用户知道密码,即可保护用户信息。
实验演示
我们在html网页中加入登录框,在提交后设置 cookie 信息,简单演示一下 cookie 的设置。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
<!-- 添加登录表单 -->
<form action="/login" method="post">
<div>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
</body>
</html>
同时我们在 handler 中加入 Cookie 的设置
head += "Set-Cookie: user=zhangsan123\n";
我们访问网站尝试登录
我们可以在浏览器中查看设置的 Cookie。
可以看到 Cookie 被成功设置,我们再次提交,查看请求信息。
可以看到请求自动加上了 Cookie 信息。
同时我们也可以看到请求正文是我们提交的参数,在这里我们简单介绍一下 GET 方法与 POST 方法的参数传递 。
- GET方法: 参数附加在URL上,GET请求将参数作为URL的一部分进行传递。参数在“?”后面以键值对的形式出现,多个参数之间用“&”分隔。
- POST方法:参数在请求体中传递,POST请求将参数放在请求体中,不会显示在URL中。
HTTPS
HTTP协议是明文传输的,所以HTTP实际上是不安全的,请求与响应的信息都可以被监听与截获。而一旦被监听或截获由于其明文传输的特点,攻击者可以读取或篡改传输的数据。
HTTPS 是 HTTP 的安全版本,它在 HTTP 的基础上通过 SSL(Secure Socket Layer)或 TLS(Transport Layer Security)协议提供了数据加密、数据完整性和身份验证。
数据加密方法
数据加密的方法有很多种,我们简单介绍一下
对称加密
对称加密使用一个密钥加密与解密,流程如下:
对称加密的流程如下:
生成密匙:密匙可以在服务端生成,也可以在客户端生成。
发送密匙:将密钥传输至另一端。
加密:发送方发送数据前使用密钥加密。
解密:接收方接受数据后使用密钥解密。
使用对称加密后,即使数据被截获,但由于攻击者没有密钥,无法完成解密,这样就保护了数据的安全。
但假如密钥在传输中被截获,那之后的解密行为就形同虚设了。下面我们介绍另一个加密算法,看他能否解决这个问题。
非对称加密
非对称加密有两个密匙:公钥,私钥。公钥是公开的,由公钥加密的数据只能由私钥解密。
非对称加密的流程:
生成密钥对:生成密钥对。私钥由自己保管,而公钥可以公开。
发送密匙:将公钥传输至另一端。
加密:发送方使用接收方的公钥对数据进行加密,然后发送给接收方。
解密:接收方使用自己的私钥对加密的数据进行解密。
非对称加密即使公钥被获取后由于私钥未公开,所以也无法对数据进行解密,因此也保证了数据的安全性。
但非对称加密通常比对称加密慢,因它常用于加密小量数据。为了保证传输速度,我们通常使用非对称加密,加密传输对称密钥的密钥。完成对称密钥的密钥传输后,采用对称加密。
混合加密
非对称加密+对称加密流程:
生成密钥对:生成密钥对。
发送密匙:将公钥传输至另一端。
生成对称密钥:生成对称加密密钥。
加密对称密钥:发送方使用接收方的公钥对对称加密密钥进行加密,然后发送给接收方。
解密对称密钥:接收方使用自己的私钥对加密的数据进行解密。
对称加密: 使用对称加密通信。
这种方法看似没有问题。即使数据在任意时刻被截获也无法对数据进行解密。
但假如数据在一开始就被劫持,攻击方将非对称加密的公钥拦截替换为自己的公钥。接受方会使用攻击方的公钥进行加密,这样使用非对称加密传输的对称公钥就可被攻击方获取。
SSL/TSL加密
SSL/TLS主要用于在网络上提供安全通信的协议。它们通过加密技术确保数据在客户端和服务器之间传输时的安全性和完整性。可以很好地解决上述问题。
SSL/TSL协议采用混合加密的方式即用非对称加密,加密传输对称密钥的密钥。完成对称密钥的密钥传输后,采用对称加密。与普通的混合加密不同,SSL/TSL协议在非对称加密是采用的是数字证书。
数字证书
数字证书由CA机构(受信任的证书颁发机构)颁发。数字证书由两部分组成:正文,数字签名。
证书正文:
- 证书所有者的信息:包括证书所有者(通常是服务器)的名称、组织、联系信息等。
- 公钥:证书所有者的公钥,用于非对称加密。在 SSL/TLS 握手过程中,这个公钥用于加密会话密钥。
- 有效期:证书的有效期限,包括开始日期和结束日期。
- 证书序列号:一个唯一的序列号,用于识别证书。
- 证书颁发者信息:签发证书的 CA 的信息。
- 扩展:可能包含额外的信息,如证书用途、证书策略、密钥用途等。
数字签名:
- CA 对证书正文的散列值进行加密,生成数字签名。这个签名是使用 CA 的私钥进行加密的,因此只有 CA 才能生成这个签名。
- 数字签名用于验证证书的真实性和完整性。当客户端接收到证书时,它会使用 CA 的公钥来解密数字签名,并将结果与证书正文的散列值进行比较。如果两者匹配,说明证书未被篡改,且确实是由可信的 CA 签发的。
数字证书的颁发流程:
- 服务器所有者向 CA 提交证书签名请求(CSR),其中包含服务器的公钥和其他身份信息。
- CA 验证服务器所有者的身份。
- 验证通过后,CA 使用其私钥对 CSR 进行签名,创建数字证书生成公钥与私钥。
- CA 将数字证书与私钥颁发给服务器所有者,后者可以将其安装在服务器上。
SSL/TSL握手
SSL/TLS握手过程:
-
客户端发起连接:
客户端(通常是浏览器)向服务器发送一个“ClientHello”消息,包含客户端支持的SSL/TLS版本、加密套件列表(即加密算法和密钥交换算法)、随机数(用于生成会话密钥)以及其他可能的扩展。 -
服务器响应:
服务器选择一个客户端支持的SSL/TLS版本和加密套件,然后发送“ServerHello”消息,包含服务器的随机数、会话ID和服务器的证书。 -
证书验证:
客户端验证服务器的证书是否由受信任的CA签发,并且证书没有过期,域名匹配等。 -
密钥交换:
服务器发送一个“ServerKeyExchange”消息(如果需要的话,例如在使用RSA密钥交换时),包含服务器的公钥或其他必要的密钥交换参数。客户端使用服务器的公钥生成一个会话密钥,并将其加密后发送给服务器,这个加密的会话密钥在“ClientKeyExchange”消息中。 -
握手结束:
客户端和服务器使用之前生成的会话密钥来加密“Finished”消息,这些消息包含了握手过程中所有消息的摘要,用于验证握手过程的完整性和验证对方的身份。 -
应用数据传输:
握手完成后,客户端和服务器开始使用协商的加密算法和会话密钥来加密和解密传输的数据。
数字证书为什么安全
客户端会通过检查证书指纹和验证证书签名是确保数字证书完整性和安全性。
检查证书指纹
证书指纹是一个独特的值,通过对证书内容进行哈希(如SHA-256)运算得到。这个指纹可以用来快速比较证书是否相同。以下是检查证书指纹的步骤:
-
获取证书:
在SSL/TLS握手过程中,服务器会向客户端提供其证书。 -
计算哈希值:
客户端使用相同的哈希算法(如SHA-256)对证书进行哈希运算,得到证书的指纹。 -
比较指纹:
客户端将计算得到的指纹与预期的指纹(通常从可信来源获得,如证书颁发机构的网站或通过安全渠道获取)进行比较。 -
验证一致性:
如果两个指纹一致,说明证书自上次验证以来未被篡改。如果不一致,证书可能已被篡改,不应信任。
验证证书的签名
证书的数字签名是使用CA的私钥对证书内容的哈希值进行加密得到的。这个签名可以验证证书的真实性和完整性。以下是验证证书签名的步骤:
- 获取CA的公钥: 通常,CA的公钥包含在其自身签发的证书中,这个证书称为根证书或中间证书。
- 提取证书签名: 从证书中提取数字签名。 计算证书的哈希值: 同样对证书内容进行哈希运算,使用与证书签名相同的哈希算法。
- 解密签名: 使用CA的公钥对数字签名进行解密。 比较哈希值: 将解密后的签名与步骤3中计算得到的哈希值进行比较。
- 验证签名: 如果哈希值与解密后的签名一致,说明证书是由CA签发的,且证书内容未被篡改。