Shiro整合JWT實現(xiàn)前后臺分離下認(rèn)證方案

JWT介紹

JSON Web Token(縮寫 JWT)是目前最流行的跨域認(rèn)證解決方案,是一個開放標(biāo)準(zhǔn)(RFC 7519),它定義了一種緊湊的、自包含的方式复亏,用于作為JSON對象在各方之間安全地傳輸信息垃你。該信息可以被驗證和信任椅文,因為它是數(shù)字簽名的。

跨域認(rèn)證的問題

HTTP協(xié)議是無狀態(tài)的惜颇,也就是說皆刺,如果我們已經(jīng)認(rèn)證了一個用戶,那么他下一次請求的時候凌摄,服務(wù)器不知道我是誰羡蛾,我們必須再次認(rèn)證∠强鳎互聯(lián)網(wǎng)服務(wù)離不開用戶認(rèn)證痴怨,一般流程是下面這樣:

  1. 用戶向服務(wù)器發(fā)送用戶名和密碼忙干。
  2. 服務(wù)器驗證通過后,在當(dāng)前對話(session)里面保存相關(guān)數(shù)據(jù)浪藻,比如用戶角色捐迫、登錄時間等等。
  3. 服務(wù)器向用戶返回一個 session_id爱葵,寫入用戶的 Cookie施戴。
  4. 用戶隨后的每一次請求,都會通過 Cookie萌丈,將session_id 傳回服務(wù)器赞哗。
  5. 服務(wù)器收到 session_id,找到前期保存的數(shù)據(jù)辆雾,由此得知用戶的身份肪笋。

這種模式的問題在于,擴展性不好度迂。單機當(dāng)然沒有問題涂乌,如果是服務(wù)器集群,或者是跨域的服務(wù)導(dǎo)向架構(gòu)英岭,就要求 session 數(shù)據(jù)共享湾盒,每臺服務(wù)器都能夠讀取 session。舉例來說诅妹,A 網(wǎng)站和 B 網(wǎng)站是同一家公司的關(guān)聯(lián)服務(wù)》9矗現(xiàn)在要求,用戶只要在其中一個網(wǎng)站登錄吭狡,再訪問另一個網(wǎng)站就會自動登錄尖殃,請問怎么實現(xiàn)?一種解決方案是 session 數(shù)據(jù)持久化划煮,寫入數(shù)據(jù)庫或別的持久層送丰。各種服務(wù)收到請求后,都向持久層請求數(shù)據(jù)弛秋。這種方案的優(yōu)點是架構(gòu)清晰器躏,缺點是工程量比較大。另外蟹略,持久層萬一掛了登失,就會單點失敗。
另一種方案是服務(wù)器索性不保存 session 數(shù)據(jù)了挖炬,所有數(shù)據(jù)都保存在客戶端揽浙,每次請求都發(fā)回服務(wù)器。JWT 就是這種方案的一個代表。

JWT 的原理與數(shù)據(jù)結(jié)構(gòu)

JWT 的原理

服務(wù)器認(rèn)證以后馅巷,生成一個 JSON 對象膛虫,發(fā)回給用戶,就像下面這樣钓猬。

{
  “姓名”: “張三”,
? “角色”: “管理員”,
? “到期時間”: “2020年7月1日0點0分”
} 

以后走敌,用戶與服務(wù)端通信的時候,都要發(fā)回這個 JSON 對象逗噩。服務(wù)器完全只靠這個對象認(rèn)定用戶身份。
為了防止用戶篡改數(shù)據(jù)跌榔,服務(wù)器在生成這個對象的時候异雁,會加上簽名。服務(wù)器就不保存任何 session 數(shù)據(jù)了僧须,也就是說纲刀,服務(wù)器變成無狀態(tài)了,從而比較容易實現(xiàn)擴展担平。

JWT 數(shù)據(jù)結(jié)構(gòu)

實際大概就像下面這樣示绊,它是一個很長的字符串,中間用點(.)分隔成三個部分暂论。JWT 的三個部分依次如下:

1.  Header(頭部)
2.  Payload(負(fù)載)
3.  Signature(簽名)
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc

寫成一行面褐,就是下面的樣子:

Header.Payload.Signature

Header

Header 部分是一個 JSON 對象,描述 JWT 的元數(shù)據(jù)取胎,通常是下面的樣子:

{
? “alg”: “HS256”,
? “typ”: “JWT”
}

上面代碼中展哭,alg屬性表示簽名的算法(algorithm),默認(rèn)是 HMAC SHA256(寫成HS256)闻蛀;typ屬性表示這個令牌(token)的類型(type)匪傍,JWT 令牌統(tǒng)一寫為JWT。
最后觉痛,將上面的 JSON 對象使用 Base64URL 算法轉(zhuǎn)成字符串役衡。

Payload

Payload 部分也是一個 JSON 對象,用來存放實際需要傳遞的數(shù)據(jù)薪棒。JWT 規(guī)定了7個官方字段手蝎,供選用:

iss (issuer):簽發(fā)人
exp (expiration time):過期時間
sub (subject):主題 aud
(audience):受眾
nbf (Not Before):生效時間
iat (Issued At):簽發(fā)時間
jti (JWT ID):編號

除了官方字段,你還可以在這個部分定義私有字段俐芯,下面就是一個例子柑船。

{
  “sub”: “1234567890”,
  “name”: “John Doe”,
  “admin”: true
}

注意,JWT 默認(rèn)是不加密的泼各,任何人都可以讀到鞍时,所以不要把秘密信息放在這個部分。
這個 JSON 對象也要使用 Base64URL 算法轉(zhuǎn)成字符串。

Signature

Signature 部分是對前兩部分的簽名逆巍,防止數(shù)據(jù)篡改及塘。
首先,需要指定一個密鑰(secret)锐极。這個密鑰只有服務(wù)器才知道笙僚,不能泄露給用戶。然后灵再,使用 Header 里面指定的簽名算法(默認(rèn)是 HMAC SHA256)肋层,按照下面的公式產(chǎn)生簽名。

HMACSHA256(base64UrlEncode(header) + “.” +base64UrlEncode(payload), secret)

算出簽名以后翎迁,把 Header栋猖、Payload、Signature 三個部分拼成一個字符串汪榔,每個部分之間用"點"(.)分隔蒲拉,就可以返回給用戶。

Base64URL

面提到痴腌,Header 和 Payload 串型化的算法是 Base64URL雌团。這個算法跟 Base64 算法基本類似,但有一些小的不同士聪。JWT 作為一個令牌(token)锦援,有些場合可能會放到 URL(比如 api.example.com/?token=xxx)。 Base64 有三個字符+剥悟、/和=雨涛,在 URL 里面有特殊含義,所以要被替換掉:=被省略懦胞、+替換成-替久,/替換成_ 。這就是 Base64URL 算法躏尉。

JWT 的使用方式

在認(rèn)證的時候蚯根,當(dāng)用戶用他們的憑證成功登錄以后,一個JSON Web Token將會被返回胀糜。此后颅拦,token就是用戶憑證了,你必須非常小心以防止出現(xiàn)安全問題教藻。一般而言距帅,你保存令牌的時候不應(yīng)該超過你所需要它的時間。
客戶端收到服務(wù)器返回的 JWT括堤,可以儲存在 Cookie 里面碌秸,也可以儲存在 localStorage绍移。
此后,客戶端每次與服務(wù)器通信讥电,都要帶上這個 JWT蹂窖。你可以把它放在 Cookie 里面自動發(fā)送,但是這樣不能跨域恩敌,所以更好的做法是放在 HTTP 請求的頭信息Authorization字段里面瞬测,因為它不使用cookie。
Authorization: Bearer token(token令牌)
另一種做法是纠炮,跨域的時候月趟,JWT 就放在 POST 請求的數(shù)據(jù)體里面。

JWT 的幾個特點

1.  JWT 默認(rèn)是不加密恢口,但也是可以加密的孝宗。生成原始 Token 以后,可以用密鑰再加密一次弧蝇。
2.  JWT 不加密的情況下,不能將私密數(shù)據(jù)寫入 JWT折砸。
3.  JWT 不僅可以用于認(rèn)證看疗,也可以用于交換信息。有效使用 JWT睦授,可以降低服務(wù)器查詢數(shù)據(jù)庫的次數(shù)两芳。
4.  JWT 的最大缺點是,由于服務(wù)器不保存 session 狀態(tài)去枷,因此無法在使用過程中廢止某個 token怖辆,或者更改 token 的權(quán)限。也就是說删顶,一旦 JWT 簽發(fā)了竖螃,在到期之前就會始終有效,除非服務(wù)器部署額外的邏輯逗余。
5.  JWT 本身包含了認(rèn)證信息特咆,一旦泄露,任何人都可以獲得該令牌的所有權(quán)限录粱。為了減少盜用腻格,JWT 的有效期應(yīng)該設(shè)置得比較短。對于一些比較重要的權(quán)限啥繁,使用時應(yīng)該再次對用戶進行認(rèn)證菜职。
6.  為了減少盜用,JWT 不應(yīng)該使用 HTTP 協(xié)議明碼傳輸旗闽,要使用 HTTPS 協(xié)議傳輸酬核。

JWT與Session的差異

1.  相同點是蜜另,它們都是存儲用戶信息;然而愁茁,Session是在服務(wù)器端的蚕钦,而JWT是在客戶端的。
2.  Session方式存儲用戶信息的最大問題在于要占用大量服務(wù)器內(nèi)存鹅很,增加服務(wù)器的開銷嘶居。
3.  JWT方式將用戶狀態(tài)分散到了客戶端中,可以明顯減輕服務(wù)端的內(nèi)存壓力促煮。
4.  Session的狀態(tài)是存儲在服務(wù)器端邮屁,客戶端只有sessionId;而Token的狀態(tài)是存儲在客戶端菠齿。

基于Token的身份認(rèn)證流程

基于Token的身份認(rèn)證是無狀態(tài)的佑吝,服務(wù)器或者Session中不會存儲任何用戶信息。沒有會話信息意味著應(yīng)用程序可以根據(jù)需要擴展和添加更多的機器绳匀,而不必?fù)?dān)心用戶登錄的位置芋忿。
雖然這一實現(xiàn)可能會有所不同,但其主要流程如下:
用戶攜帶用戶名和密碼請求訪問.
服務(wù)器校驗用戶憑據(jù).
應(yīng)用提供一個token給客戶端.
客戶端存儲token疾棵,并且在隨后的每一次請求中都帶著它.
服務(wù)器校驗token并返回數(shù)據(jù).
注意:
每一次請求都需要token戈钢,Token應(yīng)該放在請求header中。

使用Token的優(yōu)點

  • 無狀態(tài)和可擴展性:Token存儲在客戶端是尔。完全無狀態(tài)殉了,可擴展。我們的負(fù)載均衡器可以將用戶傳遞到任意服務(wù)器拟枚,因為在任何地方都沒有狀態(tài)或會話信息薪铜。
  • 安全:Token不是Cookie。(The token, not a cookie.)每次請求的時候Token都會被發(fā)送恩溅。而且隔箍,由于沒有Cookie被發(fā)送,還有助于防止CSRF攻擊脚乡。即使在你的實現(xiàn)中將token存儲到客戶端的Cookie中鞍恢,這個Cookie也只是一種存儲機制,而非身份認(rèn)證機制每窖。沒有基于會話的信息可以操作帮掉,因為我們沒有會話。
  • token在一段時間以后會過期窒典,這個時候用戶需要重新登錄蟆炊。這有助于我們保持安全。還有一個概念叫token撤銷瀑志,它允許我們根據(jù)相同的授權(quán)許可使特定的token甚至一組token無效涩搓。

Shiro介紹

Shiro 可以非常容易的開發(fā)出足夠好的應(yīng)用污秆,其不僅可以用在 JavaSE 環(huán)境,也可以用在 JavaEE 環(huán)境昧甘。Shiro 可以幫助我們完成:認(rèn)證良拼、授權(quán)、加密充边、會話管理庸推、與 Web 集成、緩存等浇冰。其基本功能點如下圖所示:


image.png

? Authentication:身份認(rèn)證 / 登錄贬媒,驗證用戶是不是擁有相應(yīng)的身份;
? Authorization:授權(quán)肘习,即權(quán)限驗證迟螺,驗證某個已認(rèn)證的用戶是否擁有某個權(quán)限惯退;即判斷用戶是否能做事情若专,常見的如:驗證某個用戶是否擁有某個角色豁翎。或者細(xì)粒度的驗證某個用戶對某個資源是否具有某個權(quán)限投蝉;
? Session Manager:會話管理养葵,即用戶登錄后就是一次會話,在沒有退出之前墓拜,它的所有信息都在會話中港柜;會話可以是普通 JavaSE 環(huán)境的请契,也可以是如 Web 環(huán)境的咳榜;
? Cryptography:加密,保護數(shù)據(jù)的安全性爽锥,如密碼加密存儲到數(shù)據(jù)庫涌韩,而不是明文存儲;
? Web Support:Web 支持氯夷,可以非常容易的集成到 Web 環(huán)境臣樱;
? Caching:緩存,比如用戶登錄后腮考,其用戶信息雇毫、擁有的角色 / 權(quán)限不必每次去查,這樣可以提高效率踩蔚;
? Concurrency:shiro 支持多線程應(yīng)用的并發(fā)驗證棚放,即如在一個線程中開啟另一個線程,能把權(quán)限自動傳播過去馅闽;
? Testing:提供測試支持飘蚯;
? Run As:允許一個用戶假裝為另一個用戶(如果他們允許)的身份進行訪問馍迄;
? Remember Me:記住我,這個是非常常見的功能局骤,即一次登錄后攀圈,下次再來的話不用登錄了。

Shiro的架構(gòu)

外部架構(gòu)

我們從外部來看 Shiro 峦甩,即從應(yīng)用程序角度的來觀察如何使用 Shiro 完成工作赘来。如下圖:


image.png

可以看到:應(yīng)用代碼直接交互的對象是 Subject,也就是說 Shiro 的對外 API 核心就是 Subject穴店;其每個 API 的含義:
Subject:主體撕捍,代表了當(dāng)前 “用戶”,這個用戶不一定是一個具體的人泣洞,與當(dāng)前應(yīng)用交互的任何東西都是Subject忧风,如網(wǎng)絡(luò)爬蟲,機器人等球凰,即一個抽象概念狮腿。所有 Subject 都綁定到 SecurityManager,與 Subject 的所有交互都會委托給 SecurityManager呕诉,可以把 Subject 認(rèn)為是一個門面缘厢,SecurityManager 才是實際的執(zhí)行者。
SecurityManager:安全管理器甩挫,即所有與安全有關(guān)的操作都會與 SecurityManager 交互贴硫,且它管理著所有 Subject,可以看出它是 Shiro 的核心伊者,它負(fù)責(zé)與后邊介紹的其他組件進行交互英遭,它就相當(dāng)于SpringMVC的 DispatcherServlet 前端控制器。
Realm:域亦渗,Shiro 從 Realm 獲取安全數(shù)據(jù)(如用戶挖诸、角色、權(quán)限)法精,就是說 SecurityManager 要驗證用戶身份多律,那么它需要從 Realm 獲取相應(yīng)的用戶進行比較以確定用戶身份是否合法,也需要從 Realm 得到用戶相應(yīng)的角色 / 權(quán)限進行驗證用戶是否能進行操作搂蜓,可以把 Realm 看成 DataSource狼荞,即安全數(shù)據(jù)源。
也就是說對于我們而言帮碰,最簡單的一個 Shiro 應(yīng)用:

  1. 應(yīng)用代碼通過 Subject 來進行認(rèn)證和授權(quán)相味,而 Subject 又委托給 SecurityManager;
  2. 我們需要給 Shiro 的 SecurityManager 注入 Realm收毫,從而讓 SecurityManager 能得到合法的用戶及其權(quán)限進行判斷攻走。
    所以Shiro 不提供維護用戶 / 權(quán)限殷勘,而是通過 Realm 讓開發(fā)人員自己注入。

內(nèi)部架構(gòu)

我們來從Shiro內(nèi)部來看下 Shiro 的架構(gòu)昔搂,如下圖所示:

image.png

? Subject:主體玲销,可以看到主體可以是任何可以與應(yīng)用交互的 “用戶”;
? SecurityManager:相當(dāng)于 SpringMVC 中的 DispatcherServlet 或者 Struts2 中的 FilterDispatcher摘符;是 Shiro 的心臟贤斜;所有具體的交互都通過 SecurityManager 進行控制;它管理著所有 Subject逛裤、且負(fù)責(zé)進行認(rèn)證和授權(quán)瘩绒、及會話、緩存的管理带族;
? Authenticator:認(rèn)證器锁荔,負(fù)責(zé)主體認(rèn)證的,這是一個擴展點蝙砌,如果用戶覺得 Shiro 默認(rèn)的不好阳堕,可以自定義實現(xiàn);其需要認(rèn)證策略(Authentication Strategy)择克,即什么情況下算用戶認(rèn)證通過了恬总;
? Authrizer:授權(quán)器,或者訪問控制器肚邢,用來決定主體是否有權(quán)限進行相應(yīng)的操作壹堰;即控制著用戶能訪問應(yīng)用中的哪些功能;
? Realm:可以有 1 個或多個 Realm骡湖,可以認(rèn)為是安全實體數(shù)據(jù)源贱纠,即用于獲取安全實體的;可以是 JDBC 實現(xiàn)勺鸦,也可以是 LDAP 實現(xiàn)并巍,或者內(nèi)存實現(xiàn)等等目木;由用戶提供换途;注意:Shiro 不知道你的用戶 / 權(quán)限存儲在哪及以何種格式存儲;所以我們一般在應(yīng)用中都需要實現(xiàn)自己的 Realm刽射;
? SessionManager:如果寫過 Servlet 就應(yīng)該知道 Session 的概念军拟,Session 呢需要有人去管理它的生命周期,這個組件就是 SessionManager誓禁;而 Shiro 并不僅僅可以用在 Web 環(huán)境懈息,也可以用在如普通的 JavaSE 環(huán)境、EJB 等環(huán)境摹恰;所以呢辫继,Shiro 就抽象了一個自己的 Session 來管理主體與應(yīng)用之間交互的數(shù)據(jù)怒见;這樣的話,比如我們在 Web 環(huán)境用姑宽,剛開始是一臺 Web 服務(wù)器遣耍;接著又上了臺 EJB 服務(wù)器;這時想把兩臺服務(wù)器的會話數(shù)據(jù)放到一個地方炮车,這個時候就可以實現(xiàn)自己的分布式會話(如把數(shù)據(jù)放到 Memcached 服務(wù)器)舵变;
? SessionDAO:DAO 大家都用過,數(shù)據(jù)訪問對象瘦穆,用于會話的 CRUD纪隙,比如我們想把 Session 保存到數(shù)據(jù)庫,那么可以實現(xiàn)自己的 SessionDAO扛或,通過如 JDBC 寫到數(shù)據(jù)庫绵咱;比如想把 Session 放到 Memcached 中,可以實現(xiàn)自己的 Memcached SessionDAO熙兔;另外 SessionDAO 中可以使用 Cache 進行緩存麸拄,以提高性能;
? CacheManager:緩存控制器黔姜,來管理如用戶拢切、角色、權(quán)限等的緩存的秆吵;因為這些數(shù)據(jù)基本上很少去改變淮椰,放到緩存中后可以提高訪問的性能;
? Cryptography:密碼模塊纳寂,Shiro 提高了一些常見的加密組件用于如密碼加密 / 解密的主穗。

Shiro身份驗證

身份驗證,即在應(yīng)用中誰能證明他就是他本人毙芜。一般提供如他們的身份 ID 一些標(biāo)識信息來表明他就是他本人忽媒,如提供身份證,用戶名 / 密碼來證明腋粥。
在 shiro 中晦雨,用戶需要提供 principals (身份)和 credentials(證明)給 shiro,從而應(yīng)用能驗證用戶身份:
? principals:身份隘冲,即主體的標(biāo)識屬性闹瞧,可以是任何東西,如用戶名展辞、郵箱等奥邮,唯一即可。一個主體可以有多個 principals,但只有一個 Primary principals洽腺,一般是用戶名 / 密碼 / 手機號脚粟。
? credentials:證明 / 憑證,即只有主體知道的安全值蘸朋,如密碼 / 數(shù)字證書等珊楼。

最常見的 principals 和 credentials 組合就是用戶名 / 密碼了。接下來先進行一個基本的身份認(rèn)證度液,另外兩個相關(guān)的概念是之前提到的 Subject 及 Realm厕宗,分別是主體及驗證主體的數(shù)據(jù)源。

maven依賴配置

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>${shiro.version}</version>
</dependency>

登錄堕担、登出

準(zhǔn)備一些用戶身份

[users]
zhang=123
wang=123

此處使用 ini 配置文件已慢,通過 [users] 指定了兩個主體:zhang/123、wang/123霹购。

@Test
public void  testLoginLoginout(){
    //1佑惠、獲取SecurityManager工廠,此處使用Ini配置文件初始化SecurityManager
    Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
    //2齐疙、得到SecurityManager實例并綁定給SecurityUtils
    SecurityManager securityManager = factory.getInstance();
    SecurityUtils.setSecurityManager(securityManager);
    //3膜楷、得到Subject及創(chuàng)建用戶名/密碼身份驗證Token(即用戶身份/憑證)
    Subject subject = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken("zhang", "123");
    try {
        //4、登錄贞奋,即身份驗證
        subject.login(token);
    } catch (AuthenticationException e) {
        //5赌厅、身份驗證失敗
    }
    // subject.isAuthenticated()是否已認(rèn)證
    System.out.println(subject.isAuthenticated());
    //6、退出
    subject.logout();

}
?   首先通過 new IniSecurityManagerFactory 并指定一個 ini 配置文件來創(chuàng)建一個 SecurityManager 工廠轿塔;
?   接著獲取 SecurityManager 并綁定到 SecurityUtils特愿,這是一個全局設(shè)置,設(shè)置一次即可勾缭;
?   通過 SecurityUtils 得到 Subject揍障,其會自動綁定到當(dāng)前線程;如果在 web 環(huán)境在請求結(jié)束時需要解除綁定俩由;然后獲取身份驗證的 Token毒嫡,如用戶名 / 密碼;
?   調(diào)用 subject.login 方法進行登錄幻梯,其會自動委托給 SecurityManager.login 方法進行登錄兜畸;
?   如果身份驗證失敗請捕獲 AuthenticationException 或其子類,常見的如: DisabledAccountException(禁用的帳號)礼旅、LockedAccountException(鎖定的帳號)膳叨、UnknownAccountException(錯誤的帳號)洽洁、ExcessiveAttemptsException(登錄失敗次數(shù)過多)痘系、IncorrectCredentialsException (錯誤的憑證)、ExpiredCredentialsException(過期的憑證)等饿自,具體請查看其繼承關(guān)系汰翠;對于頁面的錯誤消息展示龄坪,最好使用如 “用戶名 / 密碼錯誤” 而不是 “用戶名錯誤”/“密碼錯誤”,防止一些惡意用戶非法掃描帳號庫复唤;
?   最后可以調(diào)用 subject.logout 退出健田,其會自動委托給 SecurityManager.logout 方法退出。

從如上代碼可總結(jié)出身份驗證的步驟:

  1. 收集用戶身份 / 憑證佛纫,即如用戶名 / 密碼妓局;
  2. 調(diào)用 Subject.login 進行登錄,如果失敗將得到相應(yīng)的 AuthenticationException 異常呈宇,根據(jù)異常提示用戶錯誤信息好爬;否則登錄成功;
  3. 最后調(diào)用 Subject.logout 進行退出操作甥啄。

身份認(rèn)證流程

image.png
  1. 首先調(diào)用 Subject.login(token) 進行登錄存炮,其會自動委托給 Security Manager,調(diào)用之前必須通過 SecurityUtils.setSecurityManager() 設(shè)置蜈漓;
  2. SecurityManager 負(fù)責(zé)真正的身份驗證邏輯穆桂;它會委托給 Authenticator 進行身份驗證;
  3. Authenticator 才是真正的身份驗證者融虽,Shiro API 中核心的身份認(rèn)證入口點享完,此處可以自定義插入自己的實現(xiàn);
  4. Authenticator 可能會委托給相應(yīng)的 AuthenticationStrategy 進行多 Realm 身份驗證有额,默認(rèn) ModularRealmAuthenticator 會調(diào)用 AuthenticationStrategy 進行多 Realm 身份驗證驼侠;
  5. Authenticator 會把相應(yīng)的 token 傳入 Realm,從 Realm 獲取身份驗證信息谆吴,如果沒有返回 / 拋出異常表示身份驗證失敗了倒源。此處可以配置多個 Realm,將按照相應(yīng)的順序及策略進行訪問句狼。

基于SpringBoot笋熬,Shiro整合JWT

Servlet的Session機制

Shiro在JavaWeb中使用到的就是默認(rèn)的Servlet的Session機制,大致流程如下:


image.png
  1. 用戶首次發(fā)請求腻菇。
  2. 服務(wù)器接收到請求之后胳螟,無論你有沒有權(quán)限訪問到資源,在返回響應(yīng)的時候筹吐,服務(wù)器都會生成一個Session用來儲存該用戶的信息糖耸,然后生成SessionId作為對應(yīng)的Key。
  3. 服務(wù)器會在響應(yīng)中丘薛,用jsessionId這個名字嘉竟,把這個SessionId以Cookie的方式發(fā)給客戶(就是Set-Cookie響應(yīng)頭)。
  4. 由于已經(jīng)設(shè)置了Cookie,下次訪問的時候舍扰,服務(wù)器會自動識別到這個SessionId然后找到你上次對應(yīng)的Session倦蚪。

Shiro的session機制

而結(jié)合Shiro之后,上面的第二步和第三步會發(fā)生小變化:
? 第二步边苹,服務(wù)器不但會創(chuàng)建Session陵且,還會創(chuàng)建一個Subject對象(就是Shiro中用來代表當(dāng)前用戶的類),也用這個SessionId作為Key綁定个束。
? 第三步慕购,第二次接受到請求的時候,Shiro會從請求頭中找到SessionId茬底,然后去尋找對應(yīng)的Subject然后綁定到當(dāng)前上下文脓钾,這時候Shiro就能知道來訪的是誰了。

Shiro整合JWT

思想就是用JWT token來代替原本返回的session桩警。

工作流程

image.png
  1. 用戶登錄可训。
  2. 若成功則shiro會默認(rèn)生成一個SessionId用來匹配當(dāng)前Subject對象,則我們將這個SessionId放入JWT中捶枢。
  3. 返回JWT握截。
  4. 用戶第二次攜帶JWT來訪問接口。
  5. 服務(wù)器解析JWT烂叔,獲得SessionId谨胞。
  6. 服務(wù)器把SessionId交給Shiro執(zhí)行相關(guān)認(rèn)證。

實現(xiàn)代碼

pom.xml里寫入

<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.3</version>
</dependency>

<!-- shiro權(quán)限 -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.4.0</version>
</dependency>

JWTUtil工具類.作用:用于生成token蒜鸡,驗證胯努、解析token里面的sessionId。

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;

public class JWTUtil {
    /**
     * 過期時間 1 小時
     */
    private static final long EXPIRE_TIME = 60 * 60 * 1000;
    /**
     * 密鑰
     */
    private static final String SECRET = "price";

    /**
     * 生成 token
     */
    public static String createToken(String sessionId) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(SECRET);
        // 附帶sessionId信息
        return JWT.create()
                .withClaim("sessionId", sessionId)
                //到期時間
                .withExpiresAt(date)
                //創(chuàng)建一個新的JWT逢防,并使用給定的算法進行標(biāo)記
                .sign(algorithm);
    }

    /**
     * 校驗 token 是否正確
     */
    public static boolean verify(String token, String sessionId) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(SECRET);
            //在token中附帶了sessionId信息
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("sessionId", sessionId)
                    .build();
            //驗證 token
            verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }
    
    /**
     * 獲得token中的信息叶沛,無需secret解密也能獲得
     */
    public static String getSessionId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("sessionId").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }
}

自定義JWTFilter配置
作用:攔截所有請求,驗證是否攜帶token忘朝,token是否有效灰署,無效返回401狀態(tài)碼。

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;

@Slf4j
public class JWTFilter extends BasicHttpAuthenticationFilter {

    /**
     * 這個方法首先執(zhí)行,對跨域提供支持.
     */
    @Override
    public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域時會首先發(fā)送一個option請求局嘁,這里我們給option請求直接返回正常狀態(tài)
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.onPreHandle(request, response, mappedValue);
    }

    /**
     * onPreHandle方法執(zhí)行后溉箕,執(zhí)行這個方法,判斷是否登錄悦昵,返回true肴茄,允許對接口的訪問,返回false但指,執(zhí)行onAccessDenied方法寡痰。如果帶有token,則對token進行檢查.
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
        return super.isAccessAllowed(request, response, mappedValue);
    }

    /**
     * @description: 校驗token判斷是否拒絕訪問抗楔,校驗失敗返回401
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        // 從header里面獲取token
        String token = req.getHeader("token");
        // 如果header里面token為空,從url里面獲取token
        if (!Optional.ofNullable(token).isPresent()) {
            token = req.getParameter("token");
        }
        if (Optional.ofNullable(token).isPresent()) {
            String sessionId = JWTUtil.getSessionId(token);
            if (!JWTUtil.verify(token, sessionId)) {
                res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return false;
            }
        }
        res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return false;
    }
}

重寫內(nèi)置BasicHttpAuthenticationFilter過濾器

1.  protected boolean onPreHandle(ServletRequest request, ServletResponse response) throws Exception氓癌,預(yù)處理谓谦,進行驗證之前執(zhí)行的方法贫橙,可以理解為該過濾器最先執(zhí)行的方法贪婉。該方法執(zhí)行后執(zhí)行isAccessAllowed方法壁查。
2.  protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)吓坚,該方法用于判斷是否登錄丹莲,BasicHttpAuthenticationFilter底層是通過subject.isAuthenticated()方法判斷的是否登錄的旺矾。該方法返回值:如果未登錄逝变,返回false哄尔, 進入onAccessDenied必逆。如果登錄了萍嬉,返回true, 允許訪問幅垮,不用繼續(xù)驗證腰池,可以訪問接口獲取數(shù)據(jù)。
3.  protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception判斷是否拒絕訪問忙芒。個人理解就是當(dāng)用戶沒有登錄訪問該過濾器的過濾的接口時示弓,就必須進行httpBasic驗證。

自定義Realm:CustomRealm配置
作用:用于用戶登錄認(rèn)證呵萨。

import com.iot.sys.model.SysRole;
import com.iot.sys.model.SysRoleUser;
import com.iot.sys.model.SysUser;
import com.iot.sys.model.vo.SessionVO;
import com.iot.sys.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import java.util.Optional;

@Slf4j
public class CustomRealm extends AuthorizingRealm {

    @Autowired
    @Qualifier("userServiceImpl")
    private UserService userService;

    /**
     * 默認(rèn)使用此方法進行用戶名正確與否驗證奏属。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        log.info("————身份認(rèn)證————");
        String principal = (String) authenticationToken.getPrincipal();
        SysUser sysUser = userService.selectUserByName(principal);
        if (Optional.ofNullable(sysUser).isPresent()) {
            if (principal.equals(sysUser.getUsername())) {
                SessionVO sessionVO = new SessionVO();
                BeanUtils.copyProperties(sysUser, sessionVO);
                SysRoleUser roleUser = userService.selectRoleUserByUserId(sysUser.getUserId());
                Integer flag = roleUser.getFlag();
                SysRole sysRole = userService.selectRoleByRoleId(roleUser.getSysRoleId());
                sessionVO.setSystemName(sysRole.getName());
                return new SimpleAuthenticationInfo(sessionVO, sysUser.getPassword(), ByteSource.Util.bytes(sysUser.getSalt()), this.getName());
            }
        }
        return null;
    }

    /**
     * 只有當(dāng)需要檢測用戶權(quán)限的時候才會調(diào)用此方法,例如checkRole,checkPermission之類的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.info("————權(quán)限認(rèn)證————");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        return info;
    }
}

該方法在登錄驗證的時候自動調(diào)用,登錄方法的參數(shù)authenticationToken就是subject.login(token)里面的token潮峦,我們通過該token獲取傳入的用戶名囱皿,用該用戶名查出用戶密碼信息。如果該用戶不存在忱嘹,返回null嘱腥,如果存在,將該用戶的密碼封裝在AuthenticationInfo中返回拘悦,用于shiro判斷密碼是否正確爹橱。需要注意的是,AuthenticationInfo第一個參數(shù)是用于傳遞給授權(quán)方法使用的窄做,與登錄驗證基本沒有關(guān)系愧驱,不用和登錄的token參數(shù)保持一致。簡單來說椭盏,登錄方法的原理就是通過用戶名獲取到用戶過后组砚,代表用戶名驗證成功,將用戶密碼返回用于密碼驗證(這一步是shiro內(nèi)部完成)掏颊,所以就不必保持傳入AuthenticationToken的第一個參數(shù)與AuthenticationInfo的第一個參數(shù)保持一致了糟红,因為通過用戶名沒有獲取到用戶密碼這些信息就已經(jīng)表示用戶名驗證失敗了艾帐。需要注意的是AuthenticationInfo的第一個參數(shù)雖然可以傳任意對象,但是該對象必須對獲取該用戶的角色與權(quán)限有幫助盆偿,這是第一個參數(shù)最主要的作用柒爸。
使用MD5+salt:實際應(yīng)用是將鹽和散列后的值存在數(shù)據(jù)庫中,自定義Realm從數(shù)據(jù)庫取出鹽和加密后的值由shiro完成密碼校驗事扭。

new SimpleAuthenticationInfo(sessionVO, sysUser.getPassword(), ByteSource.Util.bytes(sysUser.getSalt()), this.getName());

自定義SessionManager:MySessionManager配置
作用:之前的Session的獲取捎稚,就是在DefaultWebSessionManager里實現(xiàn)的,所以我們現(xiàn)在只需要重寫這個類求橄,把驗證token今野,解析token里面的sessionId,把sessionId返回的邏輯寫進去罐农。

import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;
import java.util.Optional;

import static org.apache.shiro.web.servlet.ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE;

public class MySessionManager extends DefaultWebSessionManager {

    public MySessionManager() {
        super();
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String token = WebUtils.toHttp(request).getHeader("token");
        // 如果header里面token為空条霜,從url里面獲取token
        if (!Optional.ofNullable(token).isPresent()) {
            token = WebUtils.toHttp(request).getParameter("token");
        }
        if (Optional.ofNullable(token).isPresent()) {
            String sessionId = JWTUtil.getSessionId(token);
            if (Optional.ofNullable(sessionId).isPresent()) {
                if (JWTUtil.verify(token, sessionId)) {
                    request.setAttribute(REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
                    request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
                    request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
                    return sessionId;
                }
            }
        }
        return null;
    }
}

ShiroConfig配置并配置JWTFilter
作用:配置過濾器,拿到所有請求涵亏,放行不需要攔截的請求宰睡,注入自定義的

CustomRealm、MySessionManager气筋。
import com.iot.common.shiro.service.CustomRealm;
import com.iot.common.shiro.service.JWTFilter;
import com.iot.common.shiro.service.MySessionManager;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

    @Bean
    public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        // 添加自己的過濾器并且取名為jwt
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        //設(shè)置我們自定義的JWT過濾器
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setSecurityManager(securityManager);
        Map<String, String> filterRuleMap = new LinkedHashMap<>();
        //放行登錄接口和其他不需要權(quán)限的接口
        filterRuleMap.put("/login/**", "anon");
        // 解析war包回調(diào)接口放行
        filterRuleMap.put("/price/select-price-name", "anon");
        // 映射路徑訪問本地pic目錄放行
        filterRuleMap.put("/pic/**", "anon");
        // 打印接口
        filterRuleMap.put("/exprotPrintExcelDownload", "anon");
        filterRuleMap.put("/file/downloadTemplates", "anon");
        // 靜態(tài)資源放行
        filterRuleMap.put("/static/**", "anon");
        // 所有請求通過我們自己的JWT Filter
        filterRuleMap.put("/**", "jwt");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 注入 securityManager
     */
    @Bean
    public SecurityManager securityManager(CustomRealm customRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 設(shè)置自定義 realm.
        securityManager.setRealm(customRealm);
        // 設(shè)置自定義會話管理器
        securityManager.setSessionManager(new MySessionManager());
        return securityManager;
    }

    @Bean
    public CustomRealm getCustomRealm() {
        CustomRealm customRealm = new CustomRealm();
        // 修改憑證校驗匹配器
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        // 設(shè)置加密算法為MD5
        credentialsMatcher.setHashAlgorithmName("MD5");
        // 設(shè)置散列次數(shù)
        credentialsMatcher.setHashIterations(1024);
        customRealm.setCredentialsMatcher(credentialsMatcher);
        return customRealm;
    }

shiro內(nèi)置過濾器如下:內(nèi)置過濾器都在org.apache.shiro.web.filter.mgt.DefaultFilter枚舉下面拆内。

image.png

Controller層
登錄邏輯

@PostMapping("")
public ResultData login(@RequestBody UserVO vo) {
    Subject subject = ShiroUtils.getSubjct();
    UsernamePasswordToken token = new UsernamePasswordToken(vo.getUsername(), vo.getPassword());
    try {
        subject.login(token);
        log.info("用戶認(rèn)證成功!q汕摹矛纹!");
    } catch (UnknownAccountException | IncorrectCredentialsException e) {
        log.info("用戶名或者密碼錯誤!");
        return ResultData.error("用戶名或者密碼錯誤光稼!");
    }
    Session session = subject.getSession();
    String sessionId = session.getId().toString();
    String toke = JWTUtil.createToken(sessionId);
    return ResultData.success(toke);
}

主要是:在登錄成功之后把這個Subject的SessionId放入JWT然后生成token或南。
String token = JWTUtil.createToken(sessionId);
以后我們就可以通過解析JWT來獲取SessionId了,而不是每次把SessionId作為Cookie返回艾君。
退出邏輯
首先采够,由于JWT令牌本身就會失效,所以如果JWT令牌失效冰垄,也就相當(dāng)與退出了蹬癌。
然后我們還可以同樣實現(xiàn)Shiro中傳統(tǒng)的手動登出:

@GetMapping("logout")
public ResultData loginOut() {
    ShiroUtils.getSubjct().logout();
    return ResultData.success();
}

這樣的話Realm中的用戶狀態(tài)就變成未認(rèn)證了,就算JWT沒過期也需要重新登錄了虹茶。
測試

登錄

image.png

獲取到了JWT逝薪,JWT里面就帶有SessionId。

不帶token請求訪問

image.png

因為不能獲得token所以無法得到該用戶對應(yīng)的sessionId蝴罪,所以被授權(quán)攔截了

攜帶token請求訪問

image.png

登出

退出

image.png

退出成功

再次攜帶token請求訪問

image.png

測試成功董济!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市要门,隨后出現(xiàn)的幾起案子虏肾,更是在濱河造成了極大的恐慌廓啊,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件封豪,死亡現(xiàn)場離奇詭異谴轮,居然都是意外死亡,警方通過查閱死者的電腦和手機吹埠,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進店門第步,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人藻雌,你說我怎么就攤上這事雌续≌陡觯” “怎么了胯杭?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長受啥。 經(jīng)常有香客問我做个,道長,這世上最難降的妖魔是什么滚局? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任居暖,我火速辦了婚禮,結(jié)果婚禮上藤肢,老公的妹妹穿的比我還像新娘太闺。我一直安慰自己,他們只是感情好嘁圈,可當(dāng)我...
    茶點故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布省骂。 她就那樣靜靜地躺著,像睡著了一般最住。 火紅的嫁衣襯著肌膚如雪钞澳。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天涨缚,我揣著相機與錄音轧粟,去河邊找鬼。 笑死脓魏,一個胖子當(dāng)著我的面吹牛兰吟,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播茂翔,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼混蔼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了檩电?” 一聲冷哼從身側(cè)響起拄丰,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤府树,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后料按,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體奄侠,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年载矿,在試婚紗的時候發(fā)現(xiàn)自己被綠了垄潮。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,039評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡闷盔,死狀恐怖弯洗,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情逢勾,我是刑警寧澤牡整,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站溺拱,受9級特大地震影響逃贝,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜迫摔,卻給世界環(huán)境...
    茶點故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一沐扳、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧句占,春花似錦沪摄、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至凹炸,卻和暖如春戏阅,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背啤它。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工奕筐, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人变骡。 一個月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓离赫,卻偏偏與公主長得像,于是被迫代替她去往敵國和親塌碌。 傳聞我的和親對象是個殘疾皇子渊胸,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,786評論 2 345