此文知识来自于:《深入分析Java_Web技术》第十章
现代session与cookie的应用
本章概要:
当我们的一个应用系统有几百台服务器时,如何解决Session在多台服务器之间共享的问题?它们还有一些安全问题,如Cookie被盗、Cookie伪造等问题应如何避免?Session与Cookie的作用都是为了保持访问用户与后端服务器的交互状态。例如,使用Cookie来传递信息时,随着Cookie个数的增多和访问量的增加,它占用的网络带宽也很大,试想假如Cookie占用200个字节,如果一天的PV有几亿,那么它要占用多少带宽?所以有大访问量时希望用Session,但是Session的致命弱点是不容易在多台服务器之间共享,这也限制了Session的使用。
1. 理解Cookie
Cookie的作用通俗地说就是当一个用户通过HTTP访问一个服务器时,这个服务器会将一些Key/Value键值对返回给客户端浏览器,并给这些数据加上一些限制条件,在条件符合时这个用户下次访问这个服务器时,数据又被完整地带回给服务器。
当初W3C设计Cookie时实际考虑的是为了记录用户在一段时间内访问Web应用的行为路径。由于HTTP是一种无状态协议,当用户的一次访问请求结束后,后端服务器就无法知道下一次来访问的还是不是上次访问的用户。例如,在一个很短的时间内,如果与用户相关的数据被频繁访问,可以针对这个数据做缓存,这样可以大大提高数据的访问性能。Cookie的作用正是如此,由于是同一个客户端发出的请求,每次发出的请求都会带有第一次访问时,服务端设置的信息,这样服务端就可以根据Cookie值来划分访问的用户了。
1.1 Cookie属性项
当前Cookie有两个版本:Version0和Version1,它们有两种设置响应头的标识,分别是“Set-Cookie”和“Set-Cookie2”。它们属性项有些不同。
Version0属性项:
属性项 | 属性项介绍 |
---|---|
NAME=VALUE | 设置要保存的Key/Value,注意这里的NAME不能和其它属性项的名字一样 |
Expires | 过期时间 |
Domain | 生成该Cookie的域名 |
Path | 该Cookie是在当前哪个路径下生成的 |
Secure | 如果设置了这个属性,那么只会在SSH连接时才会回传该Cookie |
Expires | 过期时间 |
在Java Web的Servlet规范并不支持Set-Cookie2响应头,在实际应用中Set-Cookie2的一些属性项却可以设置在Set-Cookie中。博主在查看Cookie源码,发现是支持的:
另外也可以从源码可以得知,一般所说的Cookie键值对,都是值NAME和VALUE属性,其实Cookie还有其他的属性,通过Get/Set方法进行获取和设置。
另外下面是博主在使用Chrome浏览器查看的Cookie:
1.2 Cookie如何工作
当我们用如下方式创建Cookie时:
String getCookie(Cookie[] cookies, String key) {
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(key)) {
return cookie.getValue();
}
}
}
return null;
}
@Override
public void doGet(HttpServletRequest req, HttpServletResponse res) {
Cookie[] cookies = req.getCookies();
String userName = getCookie(cookies, "userName");
String userAge = getCookie(cookies, "userAge");
if (userName == null) {
res.addCookie(new Cookie("userName", "liwenguang"));
}
if (userAge == null) {
res.addCookie(new Cookie("userAge", "22"));
}
res.getHeaders("Set-Cookie");
}
以下几点需要注意:
- 所创建Cookie的NAME不能和Set-Cookie或者Set-Cookie2的属性项值一样。
- 所创建Cookie的NAME和VALUE的值不能设置成非ASCII字符,如果要使用中文,可以通过URLEncoder将其编码。
- 当NAME和VALUE的值出现一些TOKEN字符(如“\”、“,”等)时,构建返回头会将该Cookie的Version自动设置为1。
- 当在该Cookie的属性项中出现Version为1的属性项时,构建HTTP响应头同样会将Version设置为1。
1.3 使用Cookie的限制
任何语言对Cookie的操作,其实都是让浏览器对Cookie的操作,Cookie的浏览器的特性,而浏览器对Cookie有数量限制(50个/每个域名),总大小限制(4096,Chrome没有这个限制)。
2 理解Session
前面已经介绍了Cookie可以让服务端程序跟踪每个客户端的访问,但是每次客户端的访问都必须传回这些Cookie,如果Cookie很多,则无形地增加了客户端
与服务端的数据传输量,而Session的出现正是为了解决这个问题。
同一个客户端每次和服务端交互时,不需要每次都传回所有的Cookie值,而是只要传回一个ID,这个ID是客户端第一次访问服务端时生成的,而且每个客户端是唯一的。
这样每个客户端就有了一个唯一的ID,客户端只要传回这个ID就行了,这个ID通常是NAME为JSESIONID的一个Cookie。
2.1 Session与Cookie
下面详解讲一下Session是如何基于Cookie来工作的。实际上有以下三种方式可以让Session正常工作。
- 基于URL Path Parameter,默认支持。
- 基于Cookie,如果没有修改Context容器的Cookies标识,则默认也是支持的。
- 基于SSL,默认不支持,只有connector.getAttribute("SSLEnabled")为TRUE时才支持。
在第一种情况,当浏览器不支持Cookie功能时,浏览器会将用户的SessionCookieName重写到用户请求的URL参数中,传递格式如/path/Servlet;name=value;name2=value2?Name3=value3,其中“Servlet;”后面的K-V就是要传递的Path Parameters,服务器会从这个Path Parameters中拿到用户配置的SessionCookieName。关于这个SessionCookieName,如果在web.xml中配置session-config配置项,其cookie-config下的name属性就是这个SessionCookieName的值。如果没有配置sessio-config配置项,默认的SessionCookieName就是大家熟悉的“JSESSIONID”。需要说明的一点是,与Session关联的Cookie与其他Cookie没有什么不同。接着Request根据这个SessionCookieName到Parameters中拿到Session ID并设置到request.setRequestedSessionId中。
请注意,如果客户端也支持Cookie,则Tomcat仍然会解析Cookie中的Session ID,并会覆盖URL中的Session ID。
如果是第三种情况,则会根据javax.servlet.request.ssl_session属性值设置Session ID。
2.2 Session如何工作
有了Session ID,服务端就可以创建HttpSession对象了,第一次触发通过request.getSession()
方法。如果当前的Session ID还没有对应的HttpSession对象,那么就创建一个新的,并将这个对象加到org.apache.catalina.Manager的session容器中保存。Manager类将管理所有Session生命周期,Session过期将被回收,服务器关闭,Sessoin将被序列化到磁盘等。只要这个HttpSession对象存在,用户就可以根据Session ID来获取这个对象,也就做到了对状态的保持。
从Request中获取的Session对象保存在org.apache.catalina.Manager类中,它的实现类是org.apache.catalina.session.StandardManager,通过requestedSessionId从StandardManager的Sessions集合取出对应的StandardSession对象。由于一个requestedSessionId对应一个访问的客户端,所以一个客户端也就对应了一个StandardSession对象,这个对象正是保存我们创建的Session值的。下面我们看一下StandardManager这个类是如何管理StandardSession的生命周期的。
StandardManager类负责Servlet容器中所有的StandardSession对象的生命周期管理。当Servlet容器重启或关闭时,StandardManager负责持久化没有过期的StandardSession对象,它会将所有的StandardSession对象持久化到一个以“SESSIONS。ser”为文件名的文件中。到Servlet容器重启时,也就是StandardManager初始化时,它会重新读取这个文件,解析出所有Session对象,重新保存在StandardManager的sessions集合中。
当Servlet容器关闭时StandardManager类会调用unload方法将session集合中的StandardSession对象写到“SESSIONS.ser”文件中,然后在启动时再重新恢复,注意要持久化保存Servlet容器中的Session对象,必须调用Servlet容器的stop的start命令,而不能直接结束(kill)Servlet容器的进程。
因为直接结束进程,Servlet容器没有机会调用unload方法来持久化这些Session对象。
另外,在StandardManager的sessions集合中的StandardSession对象并不是永远保存的,否则Servlet容器的内存将容易被消耗尽,所以必须给每个Session对象定义一个有效时间,超过这个时间则Session对象将被清除。在Tomcat中这个有效时间是60s(maxInactiveInterval属性通知),超过60s该Session将会过期。检查每个Session是否失效是Tomcat的一个后台线程中完成的。
除了后台进程检查Session是否失效外,当调用request.getSession()
时也会检查该Session是否过期。值得注意的是,request.getSession()
方法调用的StandardSession永远都会存在,即使与这个客户端关联的Session对象已经过期。如果过期,则又会重新创建一个全新的StandardSession对象,但是以前设置的Session值将会丢失。如果你取到了Session对象,但是通过session.getAttribute
取不到前面设置的Session值,请不要奇怪,因为很可能已经失效了,请检查以下<Manager pathname="" maxInactiveInterval="60" />中maxInactiveInterval
配置项的值,如果不想让Session过期则可以设置为-1。但是你要仔细评估一下,网站的访问量和设置的Session的大小,防止将你的Servlet容器内存撑爆。如果不想自动创建Session对象,也可以通过request.getSession(bolean create)
方法来判断与该客户端关联的Session对象是否存在。
3 Cookie安全问题
Cookie通过把所有要保存的数据通过HTTP的头部从客户端传递到服务端,又从服务端传回到客户端,所有的数据都存储在客户端的浏览器里,所以这些Cookie数据可以被访问到,通过浏览器插件可以对Cookie进行修改等。
相对而言Session的安全性要高很多,因为Session是将数据保存在服务端,只是通过Cookie传递一个SessionID而已,所以Session更适合存储用户隐私和重要的数据。
4 分布式Session框架
4.1 Cookie存在哪些问题
- 客户端Cookie存储限制
- Cookie管理的混乱,每个应用系统都自己管理每个应用使用的Cookie会导致混乱,由于通常应用系统都在同一个域名下,Cookie又有上面一条提到的限制,所以统一管理很容易出现Cookie超出限制的情况。
- 不安全,虽然通过设置HttpOnly属性防止一些私密Cookie被客户端访问,但是仍然不能保证Cookie无法被篡改。为了保证Cookie的私密性通常会对Cookie进行加密,但是维护这个加密Key也是一件麻烦的事情,无法保证定期更新加密Key也是会带来安全性问题的一个重要因素。
4.2 Cookie+Session可以解决哪些问题
下面是分布式Session框架可以解决的问题:
- Session配置的统一管理。
- Cookie使用的监控和统一规范管理。
- Session存储的多元化。
- Session配置的动态修改。
- Session加密key的定期修改。
- 充分的容灾机制,保持框架的使用稳定性。
- Session各种存储的监控和报警支持。
- Session框架的可扩展性,兼容更多的Session机制如wapSession。
- 跨域名Session与Cookie如何共享的问题。现在同一个网站可能存在多个域名,如何将Session和Cookie在不同的域名之间共享是一个具有挑战性的问题。
4.3 总体实现思路
为了达成上面所说的几个目标,我们需要一个服务订阅服务器,在应用启动时可以从这个订阅服务器订阅这个应用需要的可写Session项和可写Cookie项,这些配置的Session和Cookie可以限制这个应用能够使用哪些Session和Cookie,甚至可以通知Session和Cookie可读或可写。这样可以精确地控制哪些应用可以操作哪些Session和Cookie,可以有效控制Session的安全性和Cookie的数量。
如Session的配置项可以为如下形式:
<sessions>
<session>
<key>sessionID</key>
<cookiekey>sessionID</cookiekey>
<lifeCycle>9000</lifeCycle>
<base64>true</base64>
</session>
.......
</sessions>
Cookie的配置可以为如下形式:
<cookies>
<cookie>
<key>cookie</key>
<lifeCycle>10000</lifeCycle>
<type>1</type>
<path>/wp</path>
<domain>liwenguang.website</domain>
<decrypt>false</decrypt>
<httpOnly>false</httpOnly>
</cookie>
......
</cookies>
统一通过订阅服务器推送配置可以有效地几种管理资源,所以可以省去每个应用都来配置Cookie,简化Cookie的管理。如果应用要使用一个新增的Cookie,则可以通过一个统一的平台来申请,申请通过才将这个配置项增加到订阅服务器。如果是一个所有应用都要使用的全局Cookie,那么只需要将这个Cookie通过订阅服务器统一推送过去就行了,省去了要在每个应用中手动增加Cookie的配置。
关于这个订阅服务器现在有很多开源的配置服务器,如ZooKeeper集群管理服务器,可以统一管理所有服务器的配置文件。
由于应用是一个集群,所以不可能将创建的Session都保存在每台应用服务器的内存中,因为如果每台服务器有几十万的访问用户,那么服务器的内存可能不够用,即使内存够用,这些Session也无法同步到这个应用的所有服务器中。所以要共享这些Session必须将它们存储在一个分布式缓存中,可以随时写入和读取,而且性能要很好才能满足要求。当前能满足这个要求的系统有很多,如MemCache或者淘宝的开源分布式缓存系统Tair都是很好的选择。
解决了配置和存储问题,下面看一下如何存取Session和Cookie。
既然是一个分布式Session的处理框架,必然会重新实现HttpSession的操作接口,使得应用操作Session的对象都是我们实现的InnerHttpSession对象,这个操作必须在进入应用之前完成,所以可以配置一个filter拦截用户的请求。
先看一下如何封装HttpSession对象和拦截请求,如下时序图:
我们可以在应用的web.xml中配置一个SessionFilter,用于在请求到达MVC框架之前封装HttpServletRequest和HttpServletResponse对象,并创建我们自己的InnerHttpSession对象,把它设置到request和response对象中。这样应用系统通过request.getHttpSession()返回的就是我们创建的InnerHttpSession对象了,我们可以拦截response的addCookies设置的Cookie。
在时序图中,应用创建的所有Session对象都会保存在InnerHttpSession对象中,当用户的这次访问请求完成时,Session框架将会把这个InnerHttpSession的所有内容再更新到分布式缓存中,以便于这个用户通过其它服务器再次访问这个应用系统。另外,为了保证一些应用对Session稳定性的特殊要求,可以将一些非常关键的Session再存储到Cookie中,如当分布式缓存存在问题时,可以将部分Session存储到Cookie中,这样即使分布式缓存出现问题也不会影响关键业务的正常运行。
4.4 增加Session跨域实现
还有一个非常重要的问题就是如何处理跨域名来共享Cookie的问题。我们知道Cookie是有域名限制的,也就是在一个域名下的Cookie不能被另一个域名访问,所以如果在一个域名下已经登录成功,如何访问到另外一个域名的应用且保证登录状态仍然有效,对这个问题大型网站应该经常会遇到。如何解决这个问题呢?
下面介绍一种处理方式,流程图如下:
访问域名A时服务器A获得了session,用户访问域名B时,如果发现服务器B没有session,则302重定向跳转到中转服务器C(C你可以理解成专门取Session的域),服务器C获得了session后,则再进行302重定向到A服务器,写入session,从而完成了session跨域。(如今,大部分都是实现的单点登录SSO,来解决子系统间的跨域问题,让子系统共享顶级域名的Session、Cookie等)。
5 Cookie压缩
Cookie在HTTP的头部,所以通常的gzip和deflate针对HTTP Body的压缩不能压缩Cookie,如果Cookie的量非常大,则可以考虑将Cookie也做压缩,压缩方式是将Cookie的多个k/v对看成普通的文本,做文本压缩。压缩算法同样可以使用gzip和deflate算法,但是需要注意的一点是,根据Cookie的规范,在Cookie中不能包含控制字符,仅能包含ASCII码为34~126的可见字符。所以要将压缩后的结果再进行转码,可以进行Base32或者Base64编码。
// 使用DeflaterOutputStream压缩后再用BASE64编码
Cookie c = getCookieObject("");
HttpServletResponse res = getResponse();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DeflaterOutputStream dos = new DeflaterOutputStream(bos);
try {
dos.write(c.getValue().getBytes());
dos.close();
System.out.println("before compress length:" + c.getValue().length());
String compress = new sun.misc.BASE64Encoder().encode(bos.toByteArray());
res.addCookie(new Cookie("compress", compress));
System.out.println("after compress length:" + compress.getBytes().length);
} catch (IOException e) {
e.printStackTrace();
}
// 使用BASE64解码后再用InflaterInputStream解压
ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
byte[] compress = new sun.misc.BASE64Decoder().decodeBuffer(new String(c.getValue().getBytes()));
ByteArrayInputStream bis = new ByteArrayInputStream(compress);
InflaterInputStream inflater = new InflaterInputStream(bis);
byte[] b = new byte[1024];
int count;
while ((count = inflater.read(b)) >= 0) {
out.write(b, 0, count);
}
inflater.close();
System.out.println(out.toByteArray());
} catch (IOException e) {
e.printStackTrace();
}
6 表单重复提交问题
要防止表单重复提交,就要标识用户的每一次访问请求,使得每一次访问对服务端来说都是唯一确定的。为了标识用户的每次访问请求,可以在用户请求一个表单域时增加一个隐藏表单项,这个表单项每次都是唯一的token,如:
当用户第一次请求表单页面时生成唯一的token,并存储到用户Session中,当用户第二次请求表单页面时再生成唯一的token,覆盖Session,这样就能保证每次都只能通过请求表单页面来提交表单。
7 多终端Session统一
当前大部分网站都有了无线端,对无线端的Cookie如何处理也是很多程序员必须考虑的问题。
在无线端发展初期,后端的服务系统未必和PC的服务系统是统一的,这样就涉及在一端调用多个系统时如何做到服务端Session共享的问题了。有两个明显的例子:
一个是在无线端可能会通过手机访问无线服务端系统,如果它们两个的登录系统没有统一的话,将会非常麻烦,可能会出现二次登录的情况;
另一个是在手机上登录以后再在PC上同样访问服务端数据,Session能否共享就决定了客户端是否要再次登录。
针对这两种情况,目前都有理由的解决方案。
- 多端共享Session
多端共享Session必须要做的工作是不管是无线端还是PC端,后端的服务系统必须统一会话架构,也就是两边的登录系统必须要基于一致的会员数据结构、Cookie与Session的统一。也就是不管是PC端登录还是无线端登录,后面对应的数据结构和存储要统一,写到客户端的Cookie也必须一样,这是前提条件。
那么如何做到这一点?就是要按照我们在前面所说的实现分布式的Session框架。
上面服务端统一Session后,在同一个终端上不管是访问哪个服务端都能做到登录状态统一。例如不管是Native还是内嵌Webview,都可以拿统一的Session ID去服务端验证登录状态。
- 多终端登录
目前很多网站都会出现无线端和PC端多端登录的情况,例如可以通过扫码登录等。这些是如何实现的呢?
这里手机端在扫码之前必须是已经登录的状态,因为这样才能获取到到底是谁将要登录的信息,同时扫码的二维码也带有一个特定的标识,标识是这个客户端
通过手机端登陆了。当手机端扫码成功后,会在服务端设置这个二维码对应的标识为已经登录成功,这时PC客户端会通过将“心跳”请求发送到服务端,来验证是否已经登录成功,这样就称为一种便捷的登录方式。(博主以微信扫码登录为例,每次微信PC端的二维码都是带有一个唯一的标识,当你用登录的手机微信扫码之后,手机将你已登录的微信信息获取,并发送给微信服务端,微信服务端将二维码的唯一标识以及手机微信的账号信息绑定,发送给微信PC端,其中,微信PC二维码客户端类似Watch了某个ZK的节点进行监听,这样避免客户端每隔一段时间发送心跳)。
8 总结
Cookie和Session都是为了保持用户访问的连续状态,之所以要保持这种状态,一方面是为了方便业务实现,另一方面就是简化服务端的程序设计,提高访问性能,但是也带来了安全问题、应用的分布式部署带来的Session的同步问题以及跨域名Session的同步问题(通过单点登录避免)。