Session和Cookie
由于HTTP協(xié)議是無(wú)狀態(tài)的協(xié)議,所以服務(wù)端需要記錄用戶(hù)的狀態(tài)時(shí),就需要用某種機(jī)制來(lái)識(shí)具體的用戶(hù),這個(gè)機(jī)制就是Session歹鱼。 Session是保存在服務(wù)端的,有一個(gè)唯一標(biāo)識(shí)。在大型的網(wǎng)站,一般會(huì)有專(zhuān)門(mén)的Session服務(wù)器集群,這個(gè)時(shí)候 Session 信息都是放在內(nèi)存的蛤高。
思考一下服務(wù)端如何識(shí)別特定的客戶(hù)名党?這個(gè)時(shí)候Cookie就登場(chǎng)了。每次HTTP請(qǐng)求的時(shí)候,客戶(hù)端都會(huì)發(fā)送相應(yīng)的Cookie信息到服務(wù)端。 實(shí)際上大多數(shù)的應(yīng)用都是用 Cookie 來(lái)實(shí)現(xiàn)Session跟蹤的,第一次創(chuàng)建Session的時(shí)候,服務(wù)端會(huì)在HTTP協(xié)議中告訴客戶(hù)端, 需要在 Cookie 里面記錄一個(gè)Session ID,以后每次請(qǐng)求把這個(gè)會(huì)話ID發(fā)送到服務(wù)器,我就知道你是誰(shuí)了。 有人問(wèn),如果客戶(hù)端的瀏覽器禁用了 Cookie 怎么辦氯析?一般這種情況下,會(huì)使用一種叫做URL重寫(xiě)的技術(shù)來(lái)進(jìn)行會(huì)話跟蹤, 即每次HTTP交互,URL后面都會(huì)被附加上一個(gè)諸如 sid=xxxxx 這樣的參數(shù),服務(wù)端據(jù)此來(lái)識(shí)別用戶(hù)。
Cookie其實(shí)還可以用在一些方便用戶(hù)的場(chǎng)景下,設(shè)想你某次登陸過(guò)一個(gè)網(wǎng)站,下次登錄的時(shí)候不想再次輸入賬號(hào)了,怎么辦可霎? 這個(gè)信息可以寫(xiě)到Cookie里面,訪問(wèn)網(wǎng)站的時(shí)候,網(wǎng)站頁(yè)面的腳本可以讀取這個(gè)信息,就自動(dòng)幫你把用戶(hù)名給填了,能夠方便一下用戶(hù)。 這也是Cookie名稱(chēng)的由來(lái),給用戶(hù)的一點(diǎn)甜頭宴杀。
所以,總結(jié)一下:
- session 在服務(wù)器端,cookie 在客戶(hù)端(瀏覽器)
- session 默認(rèn)被存在在服務(wù)器的一個(gè)文件里(不是內(nèi)存)
- session 的運(yùn)行依賴(lài) session id,而 session_id是存在cookie中的,也就是說(shuō),如果瀏覽器禁用了cookie, 同時(shí)session也會(huì)失效(但是可以通過(guò)其它方式實(shí)現(xiàn),比如在 url 中傳遞 session_id)
- session 可以放在 文件癣朗、數(shù)據(jù)庫(kù)、或內(nèi)存中都可以旺罢。
- 用戶(hù)驗(yàn)證這種場(chǎng)合一般會(huì)用 session 因此,維持一個(gè)會(huì)話的核心就是客戶(hù)端的唯一標(biāo)識(shí),即 session id
種常見(jiàn)的實(shí)現(xiàn)web應(yīng)用會(huì)話管理的方式:
- 基于server端session的管理方式
- 基于cookie的管理方式
- 基于token的管理方式
基于Server端session
在早期web應(yīng)用中,通常使用服務(wù)端session來(lái)管理用戶(hù)的會(huì)話旷余。
1)服務(wù)端session是用戶(hù)第一次訪問(wèn)應(yīng)用時(shí),服務(wù)器就會(huì)創(chuàng)建的對(duì)象,代表用戶(hù)的一次會(huì)話過(guò)程,可以用來(lái)存放數(shù)據(jù)。 服務(wù)器為每一個(gè)session都分配一個(gè)唯一的sessionid,以保證每個(gè)用戶(hù)都有一個(gè)不同的session對(duì)象扁达。
2)服務(wù)器在創(chuàng)建完session后,會(huì)把sessionid通過(guò)cookie返回給用戶(hù)所在的瀏覽器,這樣當(dāng)用戶(hù)第二次及以后向服務(wù)器發(fā)送請(qǐng)求的時(shí)候, 就會(huì)通過(guò)cookie把sessionid傳回給服務(wù)器,以便服務(wù)器能夠根據(jù)sessionid找到與該用戶(hù)對(duì)應(yīng)的session對(duì)象正卧。
3)session通常有失效時(shí)間的設(shè)定,比如2個(gè)小時(shí)。當(dāng)失效時(shí)間到,服務(wù)器會(huì)銷(xiāo)毀之前的session,并創(chuàng)建新的session返回給用戶(hù)跪解。 但是只要用戶(hù)在失效時(shí)間內(nèi),有發(fā)送新的請(qǐng)求給服務(wù)器,通常服務(wù)器都會(huì)把他對(duì)應(yīng)的session的失效時(shí)間根據(jù)當(dāng)前的請(qǐng)求時(shí)間再延長(zhǎng)2個(gè)小時(shí)炉旷。
4)session在一開(kāi)始并不具備會(huì)話管理的作用。它只有在用戶(hù)登錄認(rèn)證成功之后,并且往sesssion對(duì)象里面放入了用戶(hù)登錄成功的憑證, 才能用來(lái)管理會(huì)話。管理會(huì)話的邏輯也很簡(jiǎn)單,只要拿到用戶(hù)的session對(duì)象,看它里面有沒(méi)有登錄成功的憑證,就能判斷這個(gè)用戶(hù)是否已經(jīng)登錄窘行。 當(dāng)用戶(hù)主動(dòng)退出的時(shí)候,會(huì)把它的session對(duì)象里的登錄憑證清掉饥追。 所以在用戶(hù)登錄前或退出后或者session對(duì)象失效時(shí),肯定都是拿不到需要的登錄憑證的。
可簡(jiǎn)單使用流程圖描述如下:
主流的web開(kāi)發(fā)平臺(tái)都原生支持這種會(huì)話管理的方式,而且開(kāi)發(fā)起來(lái)很簡(jiǎn)單罐盔。它還有一個(gè)比較大的優(yōu)點(diǎn)就是安全性好, 因?yàn)樵跒g覽器端與服務(wù)器端保持會(huì)話狀態(tài)的媒介始終只是一個(gè)sessionid串,只要這個(gè)串夠隨機(jī),攻擊者就不能輕易冒充他人的sessionid進(jìn)行操作但绕; 除非通過(guò)CSRF或http劫持的方式,才有可能冒充別人進(jìn)行操作;即使冒充成功,也必須被冒充的用戶(hù)session里面包含有效的登錄憑證才行惶看。
但是這種方式也有幾個(gè)問(wèn)題需要解決:
1)這種方式將會(huì)話信息存儲(chǔ)在web服務(wù)器里面,所以在用戶(hù)同時(shí)在線量比較多時(shí),這些會(huì)話信息會(huì)占據(jù)比較多的內(nèi)存捏顺;
2)當(dāng)應(yīng)用采用集群部署的時(shí)候,會(huì)遇到多臺(tái)web服務(wù)器之間如何做session共享的問(wèn)題。因?yàn)閟ession是由單個(gè)服務(wù)器創(chuàng)建的, 但是處理用戶(hù)請(qǐng)求的服務(wù)器不一定是那個(gè)創(chuàng)建session的服務(wù)器,這樣他就拿不到之前已經(jīng)放入到session中的登錄憑證之類(lèi)的信息了纬黎;
3)多個(gè)應(yīng)用要共享session時(shí),除了以上問(wèn)題,還會(huì)遇到跨域問(wèn)題,因?yàn)椴煌膽?yīng)用可能部署的主機(jī)不一樣,需要在各個(gè)應(yīng)用做好cookie跨域的處理幅骄。
針對(duì)問(wèn)題1和問(wèn)題2,我見(jiàn)過(guò)的解決方案是采用redis/memcached這種中間服務(wù)器來(lái)管理session的增刪改查, 一來(lái)減輕web服務(wù)器的負(fù)擔(dān),二來(lái)解決不同web服務(wù)器共享session的問(wèn)題。
針對(duì)問(wèn)題3,由于服務(wù)端的session依賴(lài)cookie來(lái)傳遞sessionid,所以在實(shí)際項(xiàng)目中,只要解決各個(gè)項(xiàng)目里面如何實(shí)現(xiàn)sessionid的cookie跨域訪問(wèn)即可, 這個(gè)是可以實(shí)現(xiàn)的,就是比較麻煩,前后端有可能都要做處理莹桅。
如果在一些小型的web應(yīng)用中使用,可以不用考慮上面三個(gè)問(wèn)題,所以很適合這種方式昌执。
基于cookie
由于前一種方式會(huì)增加服務(wù)器的負(fù)擔(dān)和架構(gòu)的復(fù)雜性,所以后來(lái)就有人想出直接把用戶(hù)的登錄憑證直接存到客戶(hù)端的方案, 當(dāng)用戶(hù)登錄成功之后,把登錄憑證寫(xiě)到cookie里面,并給cookie設(shè)置有效期,后續(xù)請(qǐng)求直接驗(yàn)證存有登錄憑證的cookie是否存在以及憑證是否有效, 即可判斷用戶(hù)的登錄狀態(tài)。使用它來(lái)實(shí)現(xiàn)會(huì)話管理的整體流程如下:
1)用戶(hù)發(fā)起登錄請(qǐng)求,服務(wù)端根據(jù)傳入的用戶(hù)密碼之類(lèi)的身份信息,驗(yàn)證用戶(hù)是否滿(mǎn)足登錄條件,如果滿(mǎn)足,就根據(jù)用戶(hù)信息創(chuàng)建一個(gè)登錄憑證, 這個(gè)登錄憑證簡(jiǎn)單來(lái)說(shuō)就是一個(gè)對(duì)象,最簡(jiǎn)單的形式可以只包含用戶(hù)id,憑證創(chuàng)建時(shí)間和過(guò)期時(shí)間三個(gè)值诈泼。
2)服務(wù)端把上一步創(chuàng)建好的登錄憑證,先對(duì)它做數(shù)字簽名,然后再用對(duì)稱(chēng)加密算法做加密處理,將簽名懂拾、加密后的字串,寫(xiě)入cookie。 cookie的名字必須固定(如ticket),因?yàn)楹竺嬖佾@取的時(shí)候,還得根據(jù)這個(gè)名字來(lái)獲取cookie值铐达。 這一步添加數(shù)字簽名的目的是防止登錄憑證里的信息被篡改,因?yàn)橐坏┬畔⒈淮鄹?那么下一步做簽名驗(yàn)證的時(shí)候肯定會(huì)失敗岖赋。 做加密的目的,是防止cookie被別人截取的時(shí)候,無(wú)法輕易讀到其中的用戶(hù)信息。
3)用戶(hù)登錄后發(fā)起后續(xù)請(qǐng)求,服務(wù)端根據(jù)上一步存登錄憑證的cookie名字,獲取到相關(guān)的cookie值瓮孙。然后先做解密處理,再做數(shù)字簽名的認(rèn)證, 如果這兩步都失敗,說(shuō)明這個(gè)登錄憑證非法唐断;如果這兩步成功,接著就可以拿到原始存入的登錄憑證了。然后用這個(gè)憑證的過(guò)期時(shí)間和當(dāng)前時(shí)間做對(duì)比, 判斷憑證是否過(guò)期,如果過(guò)期,就需要用戶(hù)再重新登錄杭抠;如果未過(guò)期,則允許請(qǐng)求繼續(xù)脸甘。
可簡(jiǎn)單使用流程圖描述如下:
它的缺點(diǎn)也比較明顯:
1)cookie有大小限制,存儲(chǔ)不了太多數(shù)據(jù)
2)每次傳送cookie,增加了請(qǐng)求的數(shù)量,對(duì)訪問(wèn)性能也有影響;
3)也有跨域問(wèn)題,畢竟還是要用cookie偏灿。
相比起第一種方式,基于cookie方案明顯還是要好一些,目前好多web開(kāi)發(fā)平臺(tái)或框架都默認(rèn)使用這種方式來(lái)做會(huì)話管理丹诀。
跨域的問(wèn)題可以用CORS(跨域資源共享)的方式來(lái)快速解決。
基于token
前面兩種會(huì)話管理方式因?yàn)槎加玫絚ookie,不適合用在移動(dòng)端native app里面,native app不好管理cookie,畢竟它不是瀏覽器翁垂。 這兩種方案都不適合用來(lái)做純api服務(wù)的登錄認(rèn)證,就要考慮第三種會(huì)話管理方式,也就是token認(rèn)證铆遭。
這種方式從流程和實(shí)現(xiàn)上來(lái)說(shuō),跟cookie-based的方式?jīng)]有太多區(qū)別,只不過(guò)cookie-based里面寫(xiě)到cookie里面的ticket在這種方式下稱(chēng)為token, 這個(gè)token在返回給客戶(hù)端之后,后續(xù)請(qǐng)求都必須通過(guò)url參數(shù)或者是http header的形式,主動(dòng)帶上token, 這樣服務(wù)端接收到請(qǐng)求之后就能直接從http header或者url里面取到token進(jìn)行驗(yàn)證:
這種方式不通過(guò)cookie進(jìn)行token的傳遞,而是每次請(qǐng)求的時(shí)候,主動(dòng)把token加到http header里面或者url后面, 所以即使在native app里面也能使用它來(lái)調(diào)用我們通過(guò)web發(fā)布的api接口。app里面還要做兩件事情:
1)有效存儲(chǔ)token,得保證每次調(diào)接口的時(shí)候都能從同一個(gè)位置拿到同一個(gè)token沿猜;
2)每次調(diào)接口的的代碼里都得把token加到header或者接口地址里面枚荣。
可簡(jiǎn)單使用流程圖描述如下:
這種方式同樣適用于網(wǎng)頁(yè)應(yīng)用,token可以存于localStorage或者sessionStorage里面,然后每發(fā)ajax請(qǐng)求的時(shí)候, 都把token拿出來(lái)放到ajax請(qǐng)求的header里即可。不過(guò)如果是非接口的請(qǐng)求,比如直接通過(guò)點(diǎn)擊鏈接請(qǐng)求一個(gè)頁(yè)面這種, 是無(wú)法自動(dòng)帶上token的啼肩。所以這種方式也僅限于走純接口的web應(yīng)用橄妆。
基于token的標(biāo)準(zhǔn)實(shí)現(xiàn): JWT
現(xiàn)在SPA應(yīng)用,前后端完全分離,基于API接口的應(yīng)用越來(lái)越多,這時(shí)候基于token的認(rèn)證就是最好的選擇方式了衙伶。 好在這個(gè)方式的技術(shù)其實(shí)早就有很多實(shí)現(xiàn)了,而且還有現(xiàn)成的標(biāo)準(zhǔn)可用,這個(gè)標(biāo)準(zhǔn)就是JWT(json-web-token)。
JWT本身并沒(méi)有做任何技術(shù)實(shí)現(xiàn),它只是定義了token-based的管理方式該如何實(shí)現(xiàn), 它規(guī)定了token的應(yīng)該包含的標(biāo)準(zhǔn)內(nèi)容以及token的生成過(guò)程和方法呼畸。目前實(shí)現(xiàn)了這個(gè)標(biāo)準(zhǔn)的技術(shù)已經(jīng)有非常多:
官方網(wǎng)站:https://jwt.io/#libraries-io
Git主頁(yè):https://github.com/auth0/java-jwt
SpringSession
生產(chǎn)環(huán)境我們的應(yīng)用示例不可能是單節(jié)點(diǎn)部署, 通常都是多結(jié)點(diǎn)部署, 結(jié)點(diǎn)上層會(huì)進(jìn)行域映射, 實(shí)例之間負(fù)載響應(yīng)請(qǐng)求. 比如常見(jiàn)的Nginx + Tomcat負(fù)載均衡場(chǎng)景中痕支。常用的均衡算法有IP_Hash、輪訓(xùn)蛮原、根據(jù)權(quán)重卧须、隨機(jī)等。不管對(duì)于哪一種負(fù)載均衡算法儒陨,由于Nginx對(duì)不同的請(qǐng)求分發(fā)到某一個(gè)Tomcat花嘶,Tomcat在運(yùn)行的時(shí)候分別是不同的容器里,因此會(huì)出現(xiàn)session不同步或者丟失的問(wèn)題蹦漠。
解決方案
IP_HASH
nginx可以根據(jù)客戶(hù)端IP進(jìn)行負(fù)載均衡椭员,在upstream里設(shè)置ip_hash,就可以針對(duì)同一個(gè)C類(lèi)地址段中的客戶(hù)端選擇同一個(gè)后端服務(wù)器笛园,除非那個(gè)后端服務(wù)器宕了才會(huì)換一個(gè). 這樣如果該類(lèi)QPS高會(huì)導(dǎo)致該臺(tái)服務(wù)器的負(fù)載升高,負(fù)載不均.
通過(guò)容器插件
在容器層面擴(kuò)展可共享存儲(chǔ)的插件; 比如基于Tomcat的tomcat-redis-session-manager隘击,基于Jetty的jetty-session-redis等等。好處是對(duì)項(xiàng)目來(lái)說(shuō)是透明的研铆,無(wú)需改動(dòng)代碼埋同。該方案由于過(guò)于依賴(lài)容器,一旦容器升級(jí)或者更換意味著又得從新來(lái)過(guò)棵红。并且代碼不在項(xiàng)目中凶赁,對(duì)開(kāi)發(fā)者來(lái)說(shuō)維護(hù)也是個(gè)問(wèn)題。
會(huì)話管理工具
自己寫(xiě)一套會(huì)話管理的工具類(lèi)逆甜,包括Session管理和Cookie管理虱肄,在需要使用會(huì)話的時(shí)候都從自己的工具類(lèi)中獲取,而工具類(lèi)后端存儲(chǔ)可以放到Redis中交煞。很顯然這個(gè)方案靈活性最大咏窿,但開(kāi)發(fā)需要一些額外的時(shí)間。并且系統(tǒng)中存在兩套Session方案素征,很容易弄錯(cuò)而導(dǎo)致取不到數(shù)據(jù)集嵌。
開(kāi)源解決方案
這里以開(kāi)源框架Spring-Session為例,Spring-Session擴(kuò)展了Servlet的會(huì)話管理(所有的request都會(huì)經(jīng)過(guò)SessionRepositoryFilter稚茅,而 SessionRepositoryFilter是一個(gè)優(yōu)先級(jí)最高的javax.servlet.Filter纸淮,它使用了一個(gè)SessionRepositoryRequestWrapper類(lèi)接管了Http Session的創(chuàng)建和管理工作)平斩,既不依賴(lài)容器亚享,又不需要改動(dòng)代碼. 可插拔, 輕量級(jí). 支持多維度存儲(chǔ);諸如 Redis 、Pivotal GemFire绘面、Jdbc欺税、Mongo 侈沪、Hazelcast等
SpringSession應(yīng)用
SpringMvc項(xiàng)目使用SpringSession
maven依賴(lài)
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.3.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.3.1.RELEASE</version>
</dependency>
配置web.xml
<filter>
<filter-name>springSessionRepositoryFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSessionRepositoryFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
配置redis、以及redisHttpSession存儲(chǔ)
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-4.2.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd">
<context:annotation-config/>
<!-- 將session放入redis -->
<bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
<property name="maxInactiveIntervalInSeconds" value="1800" />
</bean>
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxTotal" value="100" />
<property name="maxIdle" value="10" />
</bean>
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" destroy-method="destroy">
<property name="hostName" value="127.0.0.1"/>
<property name="port" value="6379"/>
<property name="password" value="" />
<property name="timeout" value="3000"/>
<property name="usePool" value="true"/>
<property name="poolConfig" ref="jedisPoolConfig"/>
</bean>
<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
<property name="connectionFactory" ref="jedisConnectionFactory" />
</bean>
</beans>
測(cè)試代碼
@RestController
@RequestMapping("/api/cloud")
public class ApiSessionController extends BaseMultiController {
private static final Logger LOG = LoggerFactory.getLogger(ApiSessionController.class);
@Autowired
protected HttpSession httpSession;
@GetMapping("/session/put")
public APIResult sessionPut(){
httpSession.setAttribute("cloud", JSON.toJSONString(new User("Elonsu", "123456")));
String userString = (String)httpSession.getAttribute("cloud");
LOG.info("[session][set]:" + userString);
return APIResult.success(true);
}
@GetMapping("/session/get")
public APIResult sessionGet(){
String userString = (String)httpSession.getAttribute("cloud");
LOG.info("[session][get]:" + userString);
User user = JSONObject.parseObject(userString, User.class);
return APIResult.success(user);
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class User implements Serializable {
private String username;
private String password;
}
}
測(cè)試輸出
啟兩個(gè)容器實(shí)例,端口分別使用8080和8081進(jìn)行訪問(wèn)
實(shí)例1訪問(wèn)結(jié)果
實(shí)例2訪問(wèn)結(jié)果
查看redis中存儲(chǔ)的session
應(yīng)用SpringSession
maven依賴(lài)
<dependencies>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.3.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
</dependencies>
啟動(dòng)主類(lèi)增加注解
啟動(dòng)類(lèi)上增加注解@EnableRedisHttpSession
@Configuration
@SpringBootApplication
@EnableAutoConfiguration
@EnableRedisHttpSession
public class Application extends WebMvcStrap {
protected final static Logger LOG = LoggerFactory.getLogger(Application.class);
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
應(yīng)用配置文件配置
應(yīng)用配置文件application.properties
增加如下配置
# spring redis
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
# spring session
spring.session.store-type=redis
server.session.timeout=5