HTTP中的Cookie和Session
本篇介绍
前面几篇已经基本介绍了HTTP协议的大部分内容,但是前面提到了一点「HTTP是无连接、无状态的协议」,那么到底有什么无连接以及什么是无状态。基于这两个问题,随后解释什么是Cookie和Session,以及二者是如何通过代码实现的
本篇的代码部分都是基于上一节的
HttpServer
什么是无连接和无状态
HTTP最大的特点就是无连接和无状态
所谓的无连接指的是HTTP请求服务器时不需要HTTP协议自己与服务器建立连接,由于HTTP是基于TCP的,所以整个连接工作交给了TCP,这一点在前面实现HttpServer
时也有所体现
所谓的无状态指的是HTTP本身不会保存任何用户信息,举个例子,如果当前网站需要用户登录以进行后续操作,那么默认情况下,一旦这个用户登录成功后切换到其他页面就依旧会出现需要登录的情况
HTTP中的Cookie
基本介绍
既然HTTP是无状态的,那么正常情况下就会出现下面的情况:
上面图中这种情况的发送就会给用户带来糟糕的交互体验,也会给服务器带来比较大的开销,所以为了解决这个问题,就需要使用Cookie,但是因为HTTP本身是无连接的,所以Cookie本身并不是一种让HTTP从无连接变成有连接的技术,而是利用HTTP请求会携带报头的特点从而让HTTP每次请求时自动携带着Cookie
基于上面的情景,Cookie实际上实现的功能常见的就是保持用户的状态、记录用户偏好等
工作原理
Cookie工作方式与正常写入和读取HTTP请求报头属性比较类似:
- 当用户第一次访问网站时,服务器会在响应的HTTP头中设置
Set-Cookie
字段,用于发送Cookie
到用户的浏览器 - 浏览器在接收到Cookie后,会将其保存在本地(通常是按照域名进行存储)在之后的请求中,浏览器会自动在HTTP请求头中携带Cookie字段,将之前保存的Cookie信息发送给服务器
- 在之后的请求中,浏览器会自动在HTTP请求头中携带Cookie字段,将之前保存的Cookie信息发送给服务器
Cookie的分类
Cookie一般情况下分为两种:
- 会话Cookie(Session Cookie):在浏览器关闭时失效
- 持久Cookie(Persistent Cookie):带有明确的过期日期或持续时间,可以跨多个浏览器会话存在
如果Cookie是一个持久性的,那么它其实就是浏览器特定目录下的一个文件。但直接查看这些文件可能会看到乱码或无法读取的内容,因为Cookie文件通常以二进制或sqlite格式存储。所以一般想查看Cookie都只能通过浏览器提供的入口进行查看
在HttpServer
中实现Cookie功能
前置知识
要想在HttpServer
中实现给客户端添加Cookie的功能就需要用到一个响应报头属性:Set-Cookie
,其值基本形式如下:
Set-Cookie: Cookie-name=Cookie-value
其中,Cookie-name
代表的就是需要写入的Cookie的名称,Cookie-value
对应的就是Cookie的值
除了上面的基本形式外,Cookie还有一种完整形式:
Set-Cookie: Cookie-name=Cookie-value; expires=Week, Day Month Year Hour:Minutes:Seconds UTC/GMT; path=Request-path; domain=domain-name; secure; Httponly
在上面的完整形式中,每一个字段的含义如下:
字段 | 含义 |
---|---|
Cookie-name=Cookie-value | Cookie的名称和对应的值,是Set-Cookie 中唯一必需的部分 |
expires | 指定Cookie的过期时间,格式为标准HTTP日期格式。如果不设置,则为会话Cookie(浏览器关闭即失效),例如expires=Wed, 21 Oct 2025 07:28:00 GMT |
path | 指定Cookie生效的路径范围。例如:path=/admin/ 表示Cookie只在访问/admin/ 及其子路径时有效,默认为当前文档路径 |
domain | 指定Cookie生效的域名范围。例如:domain=example.com 允许Cookie在该域名及其子域名下有效,默认为当前域名(不含子域名) |
secure | 标记Cookie只能通过HTTPS安全连接传输,无值,存在即生效 |
HttpOnly | 禁止JavaScript通过document.cookie 访问此Cookie,防止XSS攻击窃取Cookie,无值,存在即生效 |
需要注意的是,如果需要给客户端发送多个Cookie,则不可以直接在同一个Cookie后方拼接,而是需要另起一个
Set-Cookie
属性
因为Cookie有简单形式和完整形式两种,所以下面先演示简单形式,再扩展到完整形式,但是完整形式中不会演示所有属性,而是针对expires
和path
这两个属性进行解释
简单形式
写入Cookie
根据Cookie的工作原理第一条,首先需要在客户端第一次请求服务端时向客户端写入一条Cookie值,所以需要在响应报头中添加一条Set-Cookie
属性,因为在上一节已经实现了一个登录函数:
void login(HttpRequest &req, HttpResponse &resp)
{LOG(LogLevel::INFO) << "进入登录模块";req.getParamKv();
}
所以本次考虑将添加Set-Cookie
属性放到当前函数中,方式很简单,只需要调用HttpResponse
类中的insertRespHead
函数即可:
!!! note
本次为了方便就不处理是否是第一次添加或者之前添加的Cookie是否存在
void login(HttpRequest &req, HttpResponse &resp)
{ // ...// 添加Cookieresp.insertRespHead("Set-Cookie", "testCookie=newCookie");
}
添加完Cookie之后就需要向响应报头中写入这一条属性,所谓的写入就是进行序列化,所以接下来需要调用HttpResponse
类中的serialize
方法以及发送对应的响应报头给客户端,但是这一步实际上已经在处理HTTP请求函数handleHttpRequest
做过了,所以就不需要再重复做了
完成上面的步骤后,接下来进行测试,需要注意,要让服务器执行login
函数就必须请求/login
方法,可以考虑直接在浏览器地址栏中请求/login
并携带部分参数,此时使用的就是GET
请求方式,也可以考虑走完整的步骤,先请求主页,再请求登录页面,通过登录请求/login
资源。本次选择前者,但是为了能够看到具体的效果,需要考虑给响应体写入一点内容,确保浏览器可以停止在该页面,所以还需要调用一个设置响应行和响应体的方法,这里在HttpResponse
中提供一个setRespLine
和setRespBody
方法:
=== “设置响应行”
// 设置响应行
void setRespLine(std::string& line)
{_resp_line = line;
}
=== “设置响应体”
void setRespBody(std::string& body)
{_resp_body = body;
}
接着,再在login
函数中通过resp
调用该方法设置响应行和响应体:
void login(HttpRequest &req, HttpResponse &resp)
{// ...// 设置响应行std::string line = default_http_ver + " " + std::to_string(200) + resp.setStatusCodeDesc(200);resp.setRespLine(line);// 设置响应体std::string body = "<h1>Hello Linux</h1>";resp.setRespBody(body);
}
测试结果如下:
但是,上面的方法只能实现发送一个Cookie给客户端,因为对于哈希表来说,key
相同,新值会覆盖旧值,当前插入的key
都是Set-Cookie
,所以会出现新的Cookie键值对覆盖旧的Cookie键值对,为了解决这个问题,可以考虑使用一个vector单独保存每一条设置Cookie的字符串
所以,首先需要一个vector成员用于存储设置Cookie的字符串,接着在设置Cookie时,应该以一个完整的字符串存储到vector中,所以实际上需要下面的方法:
void insertRespCookies(const std::string &cookie_str)
{_cookies.push_back(cookie_str);
}
接着,在序列化时直接对每一个设置Cookie的字符串添加换行符:
// 序列化
bool serialize(std::string &out_str)
{// ...// 单独为Cookie添加\r\nstd::for_each(_cookies.begin(), _cookies.end(), [&](std::string s){s += default_sep;out_str += s;});// ...return true;
}
最后,修改设置Cookie的login
函数:
void login(HttpRequest &req, HttpResponse &resp)
{// ...// 添加Cookie// resp.insertRespHead("Set-Cookie", "testCookie=newCookie");resp.insertRespCookies("Set-Cookie: testCookie=newCookie");resp.insertRespCookies("Set-Cookie: testCookie1=newCookie1");resp.insertRespCookies("Set-Cookie: testCookie2=newCookie2");// ...
}
编译运行上面的代码即可看到多条Cookie:
读取Cookie
当前,服务端已经可以向客户端写入Cookie信息,但是光写入还并不够,服务端还需要读取到对应的Cookie并对Cookie进行解析,因为客户端发送的Cookie形式为:
Cookie: testCookie=newCookie
所以需要根据这个字段格式进行解析
在前面实现HttpRequest
中已经实现了读取请求报头中的数据函数getPairFromReqHead
,所以在该类中存在一个成员key
为Cookie
,value
为testCookie=newCookie
的键值对
首先根据key
找到对应的value
,再将value
根据拆分出每一个Cookie
需要注意,尽管服务端是分开设置多个Cookie,但是在客户端给服务端发送Cookie时是合并在一个Cookie中,每一个Cookie以
;<space>
进行分隔,例如:
```
Cookie: testCookie=newCookie; testCookie1=newCookie1
```
接下来的问题就是如何拆分Cookie,拆分Cookie的思路和拆分请求体参数的方式非常类似,而因为要存储每一个Cookie键值对,所以还需要一个哈希表,所以基本结构如下:
// 键值对分隔符
const std::string default_kv_sep = "=";
// Cookie分隔符
const std::string default_cookie_sep = "; ";// HTTP请求
class HttpRequest
{
public:// ...// 获取Cookie键值对bool getCookiePairFromReqHead(){}// ...private:// ...std::unordered_map<std::string, std::string> _cookie_kv; // Cookie键值对// ...
};
接着,根据前面实现从请求体中获取参数键值对的思路实现获取Cookie键值对:
// 获取Cookie键值对
bool getCookiePairFromReqHead()
{// 先提取到Cookieauto keyPos = _kv.find("Cookie");if (keyPos == _kv.end())return false;// 在对应的value中找到Cookie值std::string cookie_value = _kv["Cookie"];auto pos = size_t(-1);while (true){// 找到;auto pos1 = cookie_value.find(default_cookie_sep, pos + 1);if (pos1 == std::string::npos){// 最后一个参数键值对auto pos_t = cookie_value.find(default_kv_sep, pos + 1);if (pos_t == std::string::npos)return false;std::string key = cookie_value.substr(pos + 1, pos_t - pos - 1);std::string value = cookie_value.substr(pos_t + 1);_cookie_kv.insert({key, value});break;}// 找到=auto pos2 = cookie_value.find(default_kv_sep, pos + 1);if (pos2 == std::string::npos)return false;std::string key = cookie_value.substr(pos + 1, pos2 - pos - 1);std::string value = cookie_value.substr(pos2 + 1, pos1 - pos2 - 1);_cookie_kv.insert({key, value});// 修改起始位置pos = pos1 + 1;}return true;
}
在上面的代码中,除了函数开始的逻辑与前面获取
POST
请求参数的逻辑不一样以外,还有更新pos
的逻辑,因为不同的Cookie之间的分隔符是两个字符,而不是一个字符
接下来,为了可以看到拆分后的结果,提供一个打印函数:
void getCookieKv()
{std::for_each(_cookie_kv.begin(), _cookie_kv.end(), [&](std::pair<std::string, std::string> kv){ std::cout << kv.first << ":" << kv.second << std::endl; });
}
有了上面的函数后,接下来就是在指定位置调用该函数:
// 反序列化
bool deserialize(std::string &in_str)
{// ...// 获取CookiegetCookiePairFromReqHead();// 打印获取到的CookiegetCookieKv();// ...return true;
}
编译运行上面的代码,观察结果:
从上面的结果可以看到,所有设置到客户端的Cookie都可以正常获取
完整形式
介绍
上面已经基本实现了简单形式下Cookie的写入和读取,但是有时简单的Cookie并不能满足实际的需求,所以除了完成简单形式以外,还需要实现完整形式的Cookie
虽然是完整形式,但是客户端最后拿到的Cookie会被浏览器进行分析,并将相关的属性填充到浏览器的相关属性中,而客户端第二次以及后面请求服务器时,携带的Cookie也只是上面简单形式的Cookie
基于上面的原因,在完整形式部分只需要实现形成某些字段的函数即可
本次只是实现完整形式中的expires
和path
,其他字段不考虑
实现expires
在实现expires
字段之前,先了解其格式:
expires=Week, Day Month Year Hour:Minutes:Seconds UTC/GMT;
第一个值为星期,第二个值为年月日,第三个值为时分秒,最后一个代表时区,UTC代表协调世界时,GMT代表格林威治平均时间,对于前面的三个字段来说都可以通过系统调用进行获取,唯独最后一个字段需要手动指定,那么选择UTC还是GMT?推荐UTC,其次再是GMT,原因如下:
UTC全称为Coordinated Universal Time (协调世界时),是现代国际社会使用的主要时间标准:基于高精度的原子钟,不受地球自转不规则性的影响,是国际标准化组织ISO推荐的官方时间标准。是现在全球互联网通信和大多数计算机系统的时间基准,格式精确统一,适合跨系统通信
GMT全称为Greenwich Mean Time (格林威治平均时间):历史上的时间标准,基于英国伦敦格林威治天文台的太阳时,其标准基于地球自转,存在微小的不规则性,在科学和技术领域已逐渐被UTC替代,但在日常交流中仍被广泛使用
在Cookie的expires
字段中:推荐使用UTC:更精确,是现代网络协议的标准做法。GMT仍然被广泛支持,大多数浏览器能正确处理。实际上两者时间差异极小(不到1秒),但从标准化和兼容性考虑,优先选择UTC
有了知识基础,接下来就是实现获取时间函数,在前面命名管道与共享内存和日志系统已经实现或者使用了一个获取时间的函数,本次实现的思路和该函数基本一致,但是当时使用的是localtime
或者localtime_r
函数,这两个函数都会自动包含时区,而实际上在设置expires
时不需要设置时区,当客户端获取到时间后会自动根据当前浏览器所处的地区设置时区,所以这里推荐使用另外一个系统调用gmtime
或者其可重入版本gmtime_r
。下面以gmtime
为例,函数原型如下:
struct tm *gmtime(const time_t *timep);
使用方式与localtime
一样,此处不再介绍。根据格式Wed, 21 Oct 2025 07:28:00 GMT
,首先需要两个额外的函数,根据月份数字获取月份字符串和根据星期数字获取星期字符串,接着再使用这两个函数获取一个完整的expires
时间:
=== “根据星期数字获取星期字符串”
std::string getWeekStrFromNumber(int w)
{// 第一个元素最好是星期天,因为gmtime以星期天为第一天const char *weekdays[7] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};if (w < 0 || w > 12)return std::string();return weekdays[w];
}
=== “根据月份数字获取月份字符串”
std::string getMonthStrFromNumber(int m)
{const char *months[12] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};if (m < 0 || m > 12)return std::string();return months[m];
}
=== “获取expires
函数”
std::string getCookieExpiresTime(int last){// 获取指定的时间之后的时间戳time_t t = time(NULL) + last;struct tm *timer = gmtime(&t);char buffer[1024] = {0};// Wed, 21 Oct 2025 07:28:00 GMT// 注意,涉及到数字的都需要两位,确保一位数可以有前导0snprintf(buffer, sizeof(buffer), "%s, %02d %s %d %02d:%02d:%02d UTC", getWeekStrFromNumber(timer->tm_wday).c_str(),timer->tm_mday,getMonthStrFromNumber(timer->tm_year).c_str(),timer->tm_year + 1900,timer->tm_hour,timer->tm_min,timer->tm_sec);return buffer;}
接着,在login
函数中创建一个带有expires
的Cookie,过期时间设置为1分钟:
void login(HttpRequest &req, HttpResponse &resp)
{// ...std::string cookie_val = "testCookie=newCookie; expires=" + resp.getCookieExpiresTime(60);resp.insertRespCookies("Set-Cookie: " + cookie_val);// ...
}
编译运行上面的代码,观察结果:
在控制台打印出实际获取到的时间如下:
可以看到,这个时间虽然没有时区,但是在浏览器显示时已经会转换成当地时区
实现path
path
的实现很简单,只需要在login
中设置Cookie时传递path
即可:
void login(HttpRequest &req, HttpResponse &resp)
{// ...resp.insertRespCookies("Set-Cookie: testCookie=newCookie; path=/login.html");// ...
}
先设置Cookie:
此时,testCookie=newCookie
一旦被设置,下一层就只会在客户端访问login.html
时才会携带该Cookie:
=== “访问主页”
=== “访问login.html
”
Cookie的安全性
在上面的测试中可以发现,不论Cookie是从服务端到客户端,还是客户端到服务端,都是可以在客户端以明文的方式看到的,如果用户输入的是账户名和密码或者其他隐私信息,那么一旦这个Cookie被劫持,就会出现用户信息泄露的问题,这对用户来说是较大的损失,所以Cookie本身是不安全的
HTTP中的Session
基本介绍
上面提到Cookie是不安全的,为了尽可能保证安全这个问题,在现代HTTP中除了使用Cookie外还会使用Session
Session与Cookie不同,Session是存储在服务器端的,当用户第一次请求服务端时,服务端会将用户的信息存储到一个特定的位置,再向客户端写入一个值为sessionID的Cookie,当用户第二次请求服务器时就会携带这个值sessionID的Cookie访问服务器,服务器会根据这个SessionID查找用户的信息,从而实现用户状态的保持
在HttpServer
中实现Session
要在HttpServer
中实现Session,首先可以考虑创建一个Session
类,这个类用于存储一个用户的相关信息,相当于之前包含用户信息的Cookie:
class Session
{
public:Session(const std::string &username, const std::string &status):_username(username), _status(status){_create_time = time(nullptr); // 获取时间戳就行了,后面实际需要,就转化就转换一下}~Session(){}
public:std::string _username;std::string _status;uint64_t _create_time;
};
因为服务器获取到的肯定不止一个Session,所以还需要一个类对Session
类对象进行管理,此处可以考虑创建一个SessionManager
类,因为sessionID是一个随机数,并且一般不能出冲突情况,所以这里考虑使用boost库中获取UUID的函数generator
:
using session_ptr = std::shared_ptr<Session>;class SessionManager
{
public:SessionManager(){}std::string addSession(session_ptr s){// 使用boost库生成uuid// 创建一个随机数生成器boost::uuids::random_generator generator;// 生成一个随机 UUIDboost::uuids::uuid id = generator();std::string sessionid = boost::uuids::to_string(id);_sessions.insert(std::make_pair(sessionid, s));return sessionid;}session_ptr getSession(const std::string sessionid){if (_sessions.find(sessionid) == _sessions.end())return nullptr;return _sessions[sessionid];}~SessionManager(){}private:std::unordered_map<std::string, session_ptr> _sessions;
};
首先考虑在HttpRequest
中创建一个SessionManager
类对象指针,并使用SessionManager
类对象进行初始化。接着在反序列化时查找出Cookie中是否有sessionid
,如果有就根据这个sessionid
查找是否已经存在于SessionManager
中,如果不存在就创建一个Session
对象并插入到SessionManager
中
但是,在上面创建Session
对象时需要使用用户名创建,而这个用户名来自请求参数,如果没有请求参数就不需要创建Session
对象,所以需要对前面获取参数的函数进行修改,使其有返回值:
bool getReqParams()
{if (_req_method == "GET"){// 处理GET请求中的参数if (getReqParamsFromReqLine()){_hasArgs = true;return true;}}else if (_req_method == "POST"){// 处理POST请求中的参数if (getReqParamsFromBody()){_hasArgs = true;return true;}}return false;
}
接着,在HttpRequest
类中添加一个成员_sessionid
,并在deserialize
函数中补充逻辑:
bool deserialize(std::string &in_str)
{// ...// 获取参数内容if(getReqParams()) {// 判断制定的sessionid是否存在,不存在则创建if (!_sm->getSession(getSessionIdFromCookiePair())){std::string username = _param_kv["username"];// 根据参数的username创建Session对象,并处于活跃状态std::shared_ptr<Session> s = std::make_shared<Session>(username, "active");// 插入到SessionManager中_sessionid = _sm->addSession(s);}}return true;
}
接着,在用户请求/login
时,创建一个Cookie,这个Cookie的值即为sessionid
,所以此处还需要在HttpRequest
中提供一个获取_sessiondid
的函数:
// 获取_sessionid
std::string getSessionId()
{return _sessionid;
}
修改login
函数如下:
void login(HttpRequest &req, HttpResponse &resp)
{// ...std::string sessionid = req.getSessionId();std::string cookie_val = "sessionid=" + sessionid;resp.insertRespCookies("Set-Cookie: " + cookie_val);// ...
}
编译上面的代码,观察结果:
Session的安全性
Session相比Cookie具有明显的安全优势。由于Session将用户敏感数据存储在服务器端,而不是客户端,只通过SessionID标识用户,大大降低了信息泄露风险。客户端仅存储一个不包含实际用户数据的SessionID,即使这个ID被截获,攻击者也无法直接获取用户的实际信息,如密码和个人资料