SpringCloud微服務(wù)實(shí)戰(zhàn)——搭建企業(yè)級(jí)開(kāi)發(fā)框架(四十):使用Spring Security OAuth2實(shí)現(xiàn)單點(diǎn)登錄(SSO)系統(tǒng)

一雪侥、單點(diǎn)登錄SSO介紹

??目前每家企業(yè)或者平臺(tái)都存在不止一套系統(tǒng)碗殷,由于歷史原因每套系統(tǒng)采購(gòu)于不同廠(chǎng)商,所以系統(tǒng)間都是相互獨(dú)立的校镐,都有自己的用戶(hù)鑒權(quán)認(rèn)證體系亿扁,當(dāng)用戶(hù)進(jìn)行登錄系統(tǒng)時(shí),不得不記住每套系統(tǒng)的用戶(hù)名密碼鸟廓,同時(shí)从祝,管理員也需要為同一個(gè)用戶(hù)設(shè)置多套系統(tǒng)登錄賬號(hào),這對(duì)系統(tǒng)的使用者來(lái)說(shuō)顯然是不方便的引谜。我們期望的是如果存在多個(gè)系統(tǒng)牍陌,只需要登錄一次就可以訪(fǎng)問(wèn)多個(gè)系統(tǒng),只需要在其中一個(gè)系統(tǒng)執(zhí)行注銷(xiāo)登錄操作员咽,則所有的系統(tǒng)都注銷(xiāo)登錄毒涧,無(wú)需重復(fù)操作,這就是單點(diǎn)登錄(Single Sign On 簡(jiǎn)稱(chēng)SSO)系統(tǒng)實(shí)現(xiàn)的功能贝室。
??單點(diǎn)登錄是系統(tǒng)功能的定義娇掏,而實(shí)現(xiàn)單點(diǎn)登錄功能唬党,目前開(kāi)源且流行的有CAS和OAuth2兩種方式装蓬,過(guò)去我們用的最多的是CAS怖糊,現(xiàn)在隨著SpringCloud的流行,更多人選擇使用SpringSecurity提供的OAuth2認(rèn)證授權(quán)服務(wù)器實(shí)現(xiàn)單點(diǎn)登錄功能峡迷。
??OAuth2是一種授權(quán)協(xié)議的標(biāo)準(zhǔn)银伟,任何人都可以基于這個(gè)標(biāo)準(zhǔn)開(kāi)發(fā)Oauth2授權(quán)服務(wù)器你虹,現(xiàn)在百度開(kāi)放平臺(tái)、騰訊開(kāi)放平臺(tái)等大部分的開(kāi)放平臺(tái)都是基于OAuth2協(xié)議實(shí)現(xiàn)彤避, OAuth2.0定義了四種授權(quán)類(lèi)型傅物,最新版OAuth2.1協(xié)議定義了七種授權(quán)類(lèi)型,其中有兩種因安全問(wèn)題已不再建議使用

【OAuth2.1 建議使用的五種授權(quán)類(lèi)型】
  • Authorization Code 【授權(quán)碼授權(quán)】:用戶(hù)通過(guò)授權(quán)服務(wù)器重定向URL返回到客戶(hù)端后琉预,應(yīng)用程序從URL中獲取授權(quán)碼董饰,并使用授權(quán)碼請(qǐng)求訪(fǎng)問(wèn)令牌。
  • PKCE【Proof Key for Code Exchange 授權(quán)碼交換證明密鑰】:授權(quán)碼類(lèi)型的擴(kuò)展圆米,用于防止CSRF和授權(quán)碼注入攻擊尖阔。
  • Client Credentials【客戶(hù)端憑證授權(quán)】:直接由客戶(hù)端使用客戶(hù)端 ID 和客戶(hù)端密鑰向授權(quán)服務(wù)器請(qǐng)求訪(fǎng)問(wèn)令牌,無(wú)需用戶(hù)授權(quán)榨咐,通常用與系統(tǒng)和系統(tǒng)之間的授權(quán)。
  • Device Code【設(shè)備代碼授權(quán)】:用于無(wú)瀏覽器或輸入受限的設(shè)備谴供,使用提前獲取好的設(shè)備代碼獲取訪(fǎng)問(wèn)令牌块茁。
  • Refresh Token【刷新令牌授權(quán)】:當(dāng)訪(fǎng)問(wèn)令牌失效時(shí),可以通過(guò)刷新令牌獲取訪(fǎng)問(wèn)令牌桂肌,不需要用戶(hù)進(jìn)行交互数焊。
【OAuth2.1 不建議/禁止使用的兩種授權(quán)類(lèi)型】
  • Implicit Flow【隱式授權(quán)】:隱式授權(quán)是以前推薦用于本機(jī)應(yīng)用程序和 JavaScript 應(yīng)用程序的簡(jiǎn)化 OAuth 流程,其中訪(fǎng)問(wèn)令牌立即返回崎场,無(wú)需額外的授權(quán)代碼交換步驟佩耳。其通過(guò)HTTP重定向直接返回訪(fǎng)問(wèn)令牌,存在很大的風(fēng)險(xiǎn)谭跨,不建議使用干厚,有些授權(quán)服務(wù)器直接禁止使用此授權(quán)類(lèi)型。
  • Password Grant【密碼授權(quán)】:客戶(hù)端通過(guò)用戶(hù)名密碼向授權(quán)服務(wù)器獲取訪(fǎng)問(wèn)令牌螃宙。因客戶(hù)端需收集用戶(hù)名和密碼蛮瞄,所以不建議使用,最新的 OAuth 2 安全最佳實(shí)踐完全不允許密碼授權(quán)谆扎。
【SpringSecurity對(duì)OAuth2協(xié)議的支持】:

??通過(guò)SpringSecurity官網(wǎng)可知挂捅,通過(guò)長(zhǎng)期的對(duì)OAuth2的支持,以及對(duì)實(shí)際業(yè)務(wù)的情景考慮堂湖,大多數(shù)的系統(tǒng)都不需要授權(quán)服務(wù)器闲先,所以,Spring官方不再推薦使用spring-security-oauth2无蜂,SpringSecurity逐漸將spring-security-oauth2中的OAuth2登錄伺糠、客戶(hù)端、資源服務(wù)器等功能抽取出來(lái)酱讶,集成在SpringSecurity中退盯,并單獨(dú)新建spring-authorization-server項(xiàng)目實(shí)現(xiàn)授權(quán)服務(wù)器功能。
??目前我們了解最多的是Spring Security OAuth對(duì)OAuth2協(xié)議的實(shí)現(xiàn)和支持,這里需要區(qū)分Spring Security OAuth和Spring Security是兩個(gè)項(xiàng)目渊迁,過(guò)去OAth2相關(guān)功能都在Spring Security OAuth項(xiàng)目中實(shí)現(xiàn)慰照,但是自SpringSecurity5.X開(kāi)始,SpringSecurity項(xiàng)目開(kāi)始逐漸增加Spring Security OAuth中的功能琉朽,自SpringSecurity5.2開(kāi)始毒租,添加了OAuth 2.0 登錄, 客戶(hù)端, 資源服務(wù)器的功能。但授權(quán)服務(wù)器的功能箱叁,并不打算集成在SpringSecurity項(xiàng)目中墅垮,而是新建了spring-authorization-server項(xiàng)目作為單獨(dú)的授權(quán)服務(wù)器:詳細(xì)介紹。spring-security實(shí)現(xiàn)的是OAuth2.1協(xié)議耕漱,spring-security-oauth2實(shí)現(xiàn)的是OAuth2.0協(xié)議算色。
??Spring未來(lái)的計(jì)劃是將 Spring Security OAuth 中當(dāng)前的所有功能構(gòu)建到 Spring Security 5.x 中。 在 Spring Security 達(dá)到與 Spring Security OAuth 的功能對(duì)等之后螟够,他們將繼續(xù)支持錯(cuò)誤和安全修復(fù)至少一年灾梦。

【GitEgg框架單點(diǎn)登錄實(shí)現(xiàn)計(jì)劃】:

??因spring-authorization-server目前最新發(fā)布版本0.2.3,部分功能仍在不斷的修復(fù)和完善妓笙,還不足以應(yīng)用到實(shí)際生產(chǎn)環(huán)境中若河,所以,我們目前使用spring-security-oauth2作為授權(quán)服務(wù)器寞宫,待后續(xù)spring-authorization-server發(fā)布穩(wěn)定版本后萧福,再進(jìn)行遷移升級(jí)。

【spring-security-oauth2默認(rèn)實(shí)現(xiàn)的授權(quán)類(lèi)型】:
  • 隱式授權(quán)(Implicit Flow)【spring-authorization-server不再支持此類(lèi)型】
  • 授權(quán)碼授權(quán)(Authorization Code)
  • 密碼授權(quán)(Password Grant)【spring-authorization-server不再支持此類(lèi)型】
  • 客戶(hù)端憑證授權(quán)(Client Credentials)
  • 刷新令牌授權(quán) (Refresh Token)

??在GitEgg微服務(wù)框架中辈赋,gitegg-oauth已經(jīng)引入了spring-security-oauth2鲫忍,代碼中使用了了Oauth2的密碼授權(quán)和刷新令牌授權(quán),并且自定義擴(kuò)展了【短信驗(yàn)證碼授權(quán)類(lèi)型】和【圖形驗(yàn)證碼授權(quán)】钥屈,這其實(shí)是密碼授權(quán)的擴(kuò)展授權(quán)類(lèi)型饲窿。
??目前,基本上所有的SpringCloud微服務(wù)授權(quán)方式都是使用的OAuth2密碼授權(quán)模式獲取token焕蹄,可能你會(huì)有疑惑逾雄,為什么上面最新的Oauth2協(xié)議已經(jīng)不建議甚至是禁止使用密碼授權(quán)類(lèi)型了,而我們GitEgg框架的系統(tǒng)管理界面還要使用密碼授權(quán)模式來(lái)獲取token腻脏?因?yàn)椴唤ㄗh使用密碼授權(quán)類(lèi)型的原因是第三方客戶(hù)端會(huì)收集用戶(hù)名密碼鸦泳,存在安全風(fēng)險(xiǎn)。而在我們這里永品,我們的客戶(hù)端是自有系統(tǒng)管理界面做鹰,不是第三方客戶(hù)端,所有的用戶(hù)名密碼都是我們自有系統(tǒng)的用戶(hù)名密碼鼎姐,只要做好系統(tǒng)安全防護(hù)钾麸,就可最大限度的避免用戶(hù)名密碼泄露給第三方的風(fēng)險(xiǎn)更振。

??在使用spring-security-oauth2實(shí)現(xiàn)單點(diǎn)登錄之前,首先我們一定要搞清楚單點(diǎn)登錄SSO饭尝、OAuth2肯腕、spring-security-oauth2的區(qū)別和聯(lián)系:
  • 單點(diǎn)登錄SSO是一種系統(tǒng)登錄解決方案的定義,企業(yè)內(nèi)部系統(tǒng)登錄以及互聯(lián)網(wǎng)上第三方QQ钥平、微信实撒、GitHub登錄等都是單點(diǎn)登錄。
  • OAuth2是一種系統(tǒng)授權(quán)協(xié)議涉瘾,它包含多種授權(quán)類(lèi)型知态,我們可以使用授權(quán)碼授權(quán)和刷新令牌授權(quán)兩種授權(quán)類(lèi)型來(lái)實(shí)現(xiàn)單點(diǎn)登錄功能。
  • spring-security-oauth2是對(duì)OAuth2協(xié)議中授權(quán)類(lèi)型的具體實(shí)現(xiàn)立叛,也是我們實(shí)現(xiàn)單點(diǎn)登錄功能實(shí)際用到的代碼负敏。

二、SpringSecurity單點(diǎn)登錄服務(wù)端和客戶(hù)端實(shí)現(xiàn)流程解析

單點(diǎn)登錄業(yè)務(wù)流程時(shí)序圖:
spring-security-oauth2單點(diǎn)登錄.png
A系統(tǒng)(單點(diǎn)登錄客戶(hù)端)首次訪(fǎng)問(wèn)受保護(hù)的資源觸發(fā)單點(diǎn)登錄流程說(shuō)明
  • 1秘蛇、用戶(hù)通過(guò)瀏覽器訪(fǎng)問(wèn)A系統(tǒng)被保護(hù)的資源鏈接
  • 2原在、A系統(tǒng)判斷當(dāng)前會(huì)話(huà)是否登錄,如果沒(méi)有登錄則跳轉(zhuǎn)到A系統(tǒng)登錄地址/login
  • 3彤叉、A系統(tǒng)首次接收到/login請(qǐng)求時(shí)沒(méi)有state和code參數(shù),此時(shí)A系統(tǒng)拼接系統(tǒng)配置的單點(diǎn)登錄服務(wù)器授權(quán)url村怪,并重定向至授權(quán)鏈接秽浇。
  • 4、單點(diǎn)登錄服務(wù)器判斷此會(huì)話(huà)是否登錄甚负,如果沒(méi)有登錄柬焕,那么返回單點(diǎn)登錄服務(wù)器的登錄頁(yè)面。
  • 5梭域、用戶(hù)在登錄頁(yè)面填寫(xiě)用戶(hù)名斑举、密碼等信息執(zhí)行登錄操作。
  • 6病涨、單點(diǎn)登錄服務(wù)器校驗(yàn)用戶(hù)名富玷、密碼并將登錄信息設(shè)置到上下文會(huì)話(huà)中。
  • 7既穆、單點(diǎn)登錄服務(wù)器重定向到A系統(tǒng)的/login鏈接赎懦,此時(shí)鏈接帶有code和state參數(shù)。
  • 8幻工、A系統(tǒng)再次接收到/login請(qǐng)求励两,此請(qǐng)求攜帶state和code參數(shù),系統(tǒng)A通過(guò)OAuth2RestTemplate請(qǐng)求單點(diǎn)登錄服務(wù)端/oauth/token接口獲取token囊颅。
  • 9当悔、A系統(tǒng)獲取到token后傅瞻,首先會(huì)對(duì)token進(jìn)行解析,并使用配置的公鑰對(duì)token進(jìn)行校驗(yàn)(非對(duì)稱(chēng)加密)盲憎,如果校驗(yàn)通過(guò)嗅骄,則將token設(shè)置到上下文,下次訪(fǎng)問(wèn)請(qǐng)求時(shí)直接從上下文中獲取焙畔。
  • 10掸读、A系統(tǒng)處理完上下問(wèn)會(huì)話(huà)之后重定向到登錄前請(qǐng)求的受保護(hù)資源鏈接。
B系統(tǒng)(單點(diǎn)登錄客戶(hù)端)訪(fǎng)問(wèn)受保護(hù)的資源流程說(shuō)明
  • 1宏多、用戶(hù)通過(guò)瀏覽器訪(fǎng)問(wèn)B系統(tǒng)被保護(hù)的資源鏈接
  • 2儿惫、B系統(tǒng)判斷當(dāng)前會(huì)話(huà)是否登錄,如果沒(méi)有登錄則跳轉(zhuǎn)到B系統(tǒng)登錄地址/login
  • 3伸但、B系統(tǒng)首次接收到/login請(qǐng)求時(shí)沒(méi)有state和code參數(shù)肾请,此時(shí)B系統(tǒng)拼接系統(tǒng)配置的單點(diǎn)登錄服務(wù)器授權(quán)url,并重定向至授權(quán)鏈接更胖。
  • 4铛铁、單點(diǎn)登錄服務(wù)器判斷此會(huì)話(huà)是否登錄,因上面訪(fǎng)問(wèn)A系統(tǒng)時(shí)登陸過(guò)却妨,所以此時(shí)不會(huì)再返回登錄界面饵逐。
  • 5、單點(diǎn)登錄服務(wù)器重定向到B系統(tǒng)的/login鏈接彪标,此時(shí)鏈接帶有code和state參數(shù)倍权。
  • 6、B系統(tǒng)再次接收到/login請(qǐng)求捞烟,此請(qǐng)求攜帶state和code參數(shù)薄声,系統(tǒng)B通過(guò)OAuth2RestTemplate請(qǐng)求單點(diǎn)登錄服務(wù)端/oauth/token接口獲取token。
  • 7题画、B系統(tǒng)獲取到token后默辨,首先會(huì)對(duì)token進(jìn)行解析,并使用配置的公鑰對(duì)token進(jìn)行校驗(yàn)(非對(duì)稱(chēng)加密)苍息,如果校驗(yàn)通過(guò)缩幸,則將token設(shè)置到上下文,下次訪(fǎng)問(wèn)請(qǐng)求時(shí)直接從上下文中獲取竞思。
  • 8桌粉、B系統(tǒng)處理完上下問(wèn)會(huì)話(huà)之后重定向到登錄前請(qǐng)求的受保護(hù)資源鏈接。
spring-security-oauth2 單點(diǎn)登錄代碼實(shí)現(xiàn)流程說(shuō)明:
  • 1衙四、用戶(hù)通過(guò)瀏覽器訪(fǎng)問(wèn)單點(diǎn)登錄被保護(hù)的資源鏈接
  • 2铃肯、SpringSecurity通過(guò)上下文判斷是否登錄(SpringSecurity單點(diǎn)登錄服務(wù)端和客戶(hù)端默認(rèn)都是基于session的),如果沒(méi)有登錄則跳轉(zhuǎn)到單點(diǎn)登錄客戶(hù)端地址/login
  • 3传蹈、單點(diǎn)登錄客戶(hù)端OAuth2ClientAuthenticationProcessingFilter攔截器通過(guò)上下文獲取token押逼,因第一次訪(fǎng)問(wèn)單點(diǎn)登錄客戶(hù)端/login時(shí)步藕,沒(méi)有code和state參數(shù),所以?huà)伋鯱serRedirectRequiredException異常
  • 4挑格、單點(diǎn)登錄客戶(hù)端捕獲UserRedirectRequiredException異常咙冗,并根據(jù)配置文件中的配置,組裝并跳轉(zhuǎn)到單點(diǎn)登錄服務(wù)端的授權(quán)鏈接/oauth/authorize漂彤,鏈接及請(qǐng)求中會(huì)帶相關(guān)配置參數(shù)
  • 5雾消、單點(diǎn)登錄服務(wù)端收到授權(quán)請(qǐng)求,根據(jù)session判斷是否此會(huì)話(huà)是否登錄挫望,如果沒(méi)有登錄則跳轉(zhuǎn)到單點(diǎn)登錄服務(wù)器的統(tǒng)一登錄界面(單點(diǎn)登錄服務(wù)端也是根據(jù)session判斷是否登錄的立润,在這里為了解決微服務(wù)的session集群共享問(wèn)題,引入了spring-session-data-redis)
  • 6媳板、用戶(hù)完成登錄操作后桑腮,單點(diǎn)登錄服務(wù)端重定向到單點(diǎn)登錄客戶(hù)端的/login鏈接,此時(shí)鏈接帶有code和state參數(shù)
  • 7蛉幸、再次用到第三步的OAuth2ClientAuthenticationProcessingFilter攔截器通過(guò)上下文獲取token破讨,此時(shí)上下文中肯定沒(méi)有token,所以會(huì)通過(guò)OAuth2RestTemplate請(qǐng)求單點(diǎn)登錄服務(wù)端/oauth/token接口使用重定向獲得的code和state換取token
  • 8奕纫、單點(diǎn)登錄客戶(hù)端獲取到token后提陶,首先會(huì)對(duì)token進(jìn)行解析,并使用配置的公鑰對(duì)token進(jìn)行校驗(yàn)(非對(duì)稱(chēng)加密)匹层,如果校驗(yàn)通過(guò)隙笆,則將token設(shè)置到上下文,下次訪(fǎng)問(wèn)請(qǐng)求時(shí)直接從上下文中獲取又固。
  • 9、單點(diǎn)登錄客戶(hù)端處理完上下問(wèn)會(huì)話(huà)之后重定向到登錄前請(qǐng)求的受保護(hù)資源鏈接煤率。

三仰冠、使用【授權(quán)碼授權(quán)】和【刷新令牌授權(quán)】來(lái)實(shí)現(xiàn)單點(diǎn)登錄服務(wù)器

1、自定義單點(diǎn)登錄服務(wù)器頁(yè)面

??當(dāng)我們的gitegg-oauth作為授權(quán)服務(wù)器使用時(shí)蝶糯,我們希望定制自己的登錄頁(yè)等信息洋只,下面我們自定義登錄、主頁(yè)昼捍、錯(cuò)誤提示頁(yè)识虚、找回密碼頁(yè)。其他需要的頁(yè)面可以自己定義妒茬,比如授權(quán)確認(rèn)頁(yè)担锤,我們此處業(yè)務(wù)不需要用戶(hù)二次確認(rèn),所以這里沒(méi)有自定義此頁(yè)面乍钻。

  • 在gitegg-oauth工程的pom.xml中添加Thymeleaf依賴(lài)肛循,作為Spring官方推薦的模板引擎铭腕,我們使用Thymeleaf來(lái)實(shí)現(xiàn)前端頁(yè)面的渲染展示。
        <!--thymeleaf 模板引擎 渲染單點(diǎn)登錄服務(wù)器頁(yè)面-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
  • 在GitEggOAuthController中新增頁(yè)面跳轉(zhuǎn)路徑
    /**
     * 單點(diǎn)登錄-登錄頁(yè)
     * @return
     */
    @GetMapping("/login") public String login() {
        return "login";
    }

    /**
     * 單點(diǎn)登錄-首頁(yè):當(dāng)直接訪(fǎng)問(wèn)單點(diǎn)登錄系統(tǒng)成功后進(jìn)入的頁(yè)面多糠。從客戶(hù)端系統(tǒng)進(jìn)入的累舷,直接返回到客戶(hù)端頁(yè)面
     * @return
     */
    @GetMapping("/index") public String index() {
        return "index";
    }

    /**
     * 單點(diǎn)登錄-錯(cuò)誤頁(yè)
     * @return
     */
    @GetMapping("/error") public String error() {
        return "error";
    }

    /**
     * 單點(diǎn)登錄-找回密碼頁(yè)
     * @return
     */
    @GetMapping("/find/pwd") public String findPwd() {
        return "findpwd";
    }
  • 在resources目錄下新建static(靜態(tài)資源)目錄和templates(頁(yè)面代碼)目錄,新增favicon.ico文件


    單點(diǎn)登錄頁(yè)面目錄
  • 自定義登錄頁(yè)login.html代碼
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="description" content="統(tǒng)一身份認(rèn)證平臺(tái)">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>統(tǒng)一身份認(rèn)證平臺(tái)</title>
    <link rel="shortcut icon" th:href="@{/gitegg-oauth/favicon.ico}"/>
    <link rel="bookmark" th:href="@{/gitegg-oauth/favicon.ico}"/>
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/bootstrap-4.3.1-dist/css/bootstrap.min.css}">
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/bootstrap-validator-0.5.3/css/bootstrapValidator.css}">
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/css/font-awesome.min.css}">
    <link type="text/css" rel="stylesheet" th:href="@{/gitegg-oauth/assets/css/login.css}">
    <!--[if IE]>
        <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/html5shiv.min.js}"></script>
    <![endif]-->
</head>
<body>
    <div class="htmleaf-container">
        <div class="form-bg">
                <div class="container">
                    <div class="row login_wrap">
                        <div class="login_left">
                            <span class="circle">
                              <!-- <span></span>
                              <span></span> -->
                              <img th:src="@{/gitegg-oauth/assets/images/logo.svg}" class="logo" alt="logo">
                            </span>
                            <span class="star">
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                              <span></span>
                            </span>
                            <span class="fly_star">
                              <span></span>
                              <span></span>
                            </span>
                            <p id="title">
                                GitEgg Cloud 統(tǒng)一身份認(rèn)證平臺(tái)
                            </p>
                        </div>
                        <div class="login_right">
                            <div class="title cf">
                                <ul class="title-list fr cf ">
                                    <li class="on">賬號(hào)密碼登錄</li>
                                    <li>驗(yàn)證碼登錄</li>
                                    <p></p>
                                </ul>
                            </div>
                            <div class="login-form-container account-login">
                                <form class="form-horizontal account-form" th:action="@{/gitegg-oauth/login}" method="post">
                                    <input type="hidden" class="form-control" name="client_id" value="gitegg-admin">
                                    <input id="user_type" type="hidden" class="form-control" name="type" value="user">
                                    <input id="user_mobileType" type="hidden" class="form-control" name="mobile" value="0">
                                    <div class="input-wrapper input-account-wrapper form-group">
                                        <div class="input-icon-wrapper">
                                            <i class="input-icon">
                                                <svg t="1646301169630" class="icon" viewBox="64 64 896 896" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8796" width="1.2em" height="1.2em" fill="currentColor"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="8797"></path></svg>
                                            </i>
                                        </div>
                                        <input type="text" class="input" name="username" placeholder="請(qǐng)輸入您的賬號(hào)">
                                    </div>
                                    <div class="input-wrapper input-psw-wrapper form-group">
                                        <div class="input-icon-wrapper">
                                            <i class="input-icon">
                                                <svg t="1646302713220" class="icon" viewBox="64 64 896 896" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8931" width="1.2em" height="1.2em" fill="currentColor"><path d="M832 464h-68V240c0-70.7-57.3-128-128-128H388c-70.7 0-128 57.3-128 128v224h-68c-17.7 0-32 14.3-32 32v384c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zM332 240c0-30.9 25.1-56 56-56h248c30.9 0 56 25.1 56 56v224H332V240z m460 600H232V536h560v304z" p-id="8932"></path><path d="M484 701v53c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-53c12.1-8.7 20-22.9 20-39 0-26.5-21.5-48-48-48s-48 21.5-48 48c0 16.1 7.9 30.3 20 39z" p-id="8933"></path></svg>
                                            </i>
                                        </div>
                                        <input id="password" type="password" class="input" name="password" placeholder="請(qǐng)輸入您的密碼">
                                    </div>
                                    <div id="account-err" class="err-msg" style="width: 100%; text-align: center;"></div>
                                    <button type="submit" class="login-btn" id="loginSubmit">立即登錄</button>
                                    <div class="forget" id="forget">忘記密碼夹孔?</div>
                                </form>
                            </div>
                            <div class="login-form-container mobile-login" style="display: none;">
                                <form class="form-horizontal mobile-form" th:action="@{/gitegg-oauth/phoneLogin}" method="post">
                                    <input id="tenantId" type="hidden" class="form-control" name="tenant_id" value="0">
                                    <input id="type" type="hidden" class="form-control" name="type" value="phone">
                                    <input id="mobileType" type="hidden" class="form-control" name="mobile" value="0">
                                    <input id="smsId" type="hidden" class="form-control" name="smsId">
                                    <div class="input-wrapper input-account-wrapper form-group input-phone-wrapper">
                                        <div class="input-icon-wrapper">
                                            <i class="input-icon">
                                                <svg t="1646302822533" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9067" width="1.2em" height="1.2em" fill="currentColor"><path d="M744 62H280c-35.3 0-64 28.7-64 64v768c0 35.3 28.7 64 64 64h464c35.3 0 64-28.7 64-64V126c0-35.3-28.7-64-64-64z m-8 824H288V134h448v752z" p-id="9068"></path><path d="M512 784m-40 0a40 40 0 1 0 80 0 40 40 0 1 0-80 0Z" p-id="9069"></path></svg>
                                            </i>
                                        </div>
                                        <input id="phone" type="text" class="input" name="phone" maxlength="11" placeholder="請(qǐng)輸入手機(jī)號(hào)">
                                    </div>
                                    <div class="code-form form-group sms-code-wrapper">
                                        <div class="input-wrapper input-sms-wrapper">
                                            <div class="input-icon-wrapper">
                                                <i class="input-icon">
                                                    <svg t="1646302879723" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9203" width="1.2em" height="1.2em" fill="currentColor"><path d="M928 160H96c-17.7 0-32 14.3-32 32v640c0 17.7 14.3 32 32 32h832c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 110.8V792H136V270.8l-27.6-21.5 39.3-50.5 42.8 33.3h643.1l42.8-33.3 39.3 50.5-27.7 21.5z" p-id="9204"></path><path d="M833.6 232L512 482 190.4 232l-42.8-33.3-39.3 50.5 27.6 21.5 341.6 265.6c20.2 15.7 48.5 15.7 68.7 0L888 270.8l27.6-21.5-39.3-50.5-42.7 33.2z" p-id="9205"></path></svg>
                                                </i>
                                            </div>
                                            <input id="code" type="text" class="input-code" name="code" maxlength="6" placeholder="請(qǐng)輸入驗(yàn)證碼">
                                        </div>
                                        <div class="input-code-wrapper">
                                            <a id="sendBtn" href="javascript:sendCode();">獲取驗(yàn)證碼</a>
                                        </div>
                                    </div>
                                    <div id="mobile-err" class="err-msg" style="width: 100%; text-align: center;"></div>
                                    <button type="submit" class="login-btn" id="loginSubmitByCode">立即登錄</button>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        <div class="related">
            Copyrights ? 2021 GitEgg All Rights Reserved. 
        </div>
    </div>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/jquery-2.1.4.min.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/bootstrap-4.3.1-dist/js/bootstrap.min.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/bootstrap-validator-0.5.3/js/bootstrapValidator.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/md5.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/jquery.form.js}"></script>
    <script type="text/javascript" th:src="@{/gitegg-oauth/assets/js/login.js}"></script>
</body>
</html>

  • 自定義登錄login.js代碼
var countdown=60;
jQuery(function ($) {
    countdown = 60;

    $('.account-form').bootstrapValidator({
        message: '輸入錯(cuò)誤',
        feedbackIcons: {
            valid: 'glyphicon glyphicon-ok',
            invalid: 'glyphicon glyphicon-remove',
            validating: 'glyphicon glyphicon-refresh'
        },
        fields: {
            username: {
                container: '.input-account-wrapper',
                message: '輸入錯(cuò)誤',
                validators: {
                    notEmpty: {
                        message: '用戶(hù)賬號(hào)不能為空'
                    },
                    stringLength: {
                        min: 2,
                        max: 32,
                        message: '賬號(hào)長(zhǎng)度范圍2-32個(gè)字符被盈。'
                    },
                    regexp: {
                        regexp: /^[a-zA-Z0-9_\.]+$/,
                        message: '用戶(hù)名只能由字母、數(shù)字搭伤、點(diǎn)和下劃線(xiàn)組成'
                    }
                }
            },
            password: {
                container: '.input-psw-wrapper',
                validators: {
                    notEmpty: {
                        message: '密碼不能為空'
                    },
                    stringLength: {
                        min: 5,
                        max: 32,
                        message: '密碼長(zhǎng)度范圍6-32個(gè)字符只怎。'
                    }
                }
            }
        }
    });

    $('.mobile-form').bootstrapValidator({
        message: '輸入錯(cuò)誤',
        feedbackIcons: {
            valid: 'glyphicon glyphicon-ok',
            invalid: 'glyphicon glyphicon-remove',
            validating: 'glyphicon glyphicon-refresh'
        },
        fields: {
            phone: {
                message: '輸入錯(cuò)誤',
                container: '.input-phone-wrapper',
                validators: {
                    notEmpty: {
                        message: '手機(jī)號(hào)不能為空'
                    },
                    regexp: {
                        regexp: /^1\d{10}$/,
                        message: '手機(jī)號(hào)格式錯(cuò)誤'
                    }
                }
            },
            code: {
                container: '.input-sms-wrapper',
                validators: {
                    notEmpty: {
                        message: '驗(yàn)證碼不能為空'
                    },
                    stringLength: {
                        min: 6,
                        max: 6,
                        message: '驗(yàn)證碼長(zhǎng)度為6位。'
                    }
                }
            }
        }
    });

    var options={
        beforeSerialize: beforeFormSerialize,
        success: formSuccess,//提交成功后執(zhí)行的回掉函數(shù)
        error: formError,//提交失敗后執(zhí)行的回掉函數(shù)
        headers : {"TenantId" : 0},
        clearForm: true,//提交成功后是否清空表單中的字段值
        restForm: true,//提交成功后是否充值表單中的字段值闷畸,即恢復(fù)到頁(yè)面加載是的狀態(tài)
        timeout: 6000//設(shè)置請(qǐng)求時(shí)間尝盼,超過(guò)時(shí)間后,自動(dòng)退出請(qǐng)求佑菩,單位(毫秒)
    }

    var mobileOptions={
        success: mobileFormSuccess,//提交成功后執(zhí)行的回掉函數(shù)
        error: mobileFormError,//提交失敗后執(zhí)行的回掉函數(shù)
        headers : {"TenantId" : 0},
        clearForm: true,//提交成功后是否清空表單中的字段值
        restForm: true,//提交成功后是否充值表單中的字段值盾沫,即恢復(fù)到頁(yè)面加載是的狀態(tài)
        timeout: 6000//設(shè)置請(qǐng)求時(shí)間,超過(guò)時(shí)間后殿漠,自動(dòng)退出請(qǐng)求赴精,單位(毫秒)
    }

    function beforeFormSerialize(){
        $("#account-err").html("");
        $("#username").val($.trim($("#username").val()));
        $("#password").val($.md5($.trim($("#password").val())));
    }

    function formSuccess(response){
        $(".account-form").data('bootstrapValidator').resetForm();
        if (response.success)
        {
            window.location.href = response.targetUrl;
        }
        else
        {
            $("#account-err").html(response.message);
        }
    }


    function formError(response){
        $("#account-err").html(response);
    }

    function mobileFormSuccess(response){
        $(".mobile-form").data('bootstrapValidator').resetForm();
        if (response.success)
        {
            window.location.href = response.targetUrl;
        }
        else
        {
            $("#mobile-err").html(response.message);
        }
    }


    function mobileFormError(response){
        $("#mobile-err").html(response);
    }

    $(".account-form").ajaxForm(options);

    $(".mobile-form").ajaxForm(mobileOptions);

    $(".nav-left a").click(function(e){
        $(".account-login").show();
        $(".mobile-login").hide();
    });

    $(".nav-right a").click(function(e){
        $(".account-login").hide();
        $(".mobile-login").show();
    });

    $("#forget").click(function(e){
        window.location.href = "/find/pwd";
    });

    $('.title-list li').click(function(){
        var liindex = $('.title-list li').index(this);
        $(this).addClass('on').siblings().removeClass('on');
        $('.login_right div.login-form-container').eq(liindex).fadeIn(150).siblings('div.login-form-container').hide();
        var liWidth = $('.title-list li').width();

        if (liindex == 0)
        {
            $('.login_right .title-list p').css("transform","translate3d(0px, 0px, 0px)");
        }
        else {
            $('.login_right .title-list p').css("transform","translate3d("+liWidth+"px, 0px, 0px)");
        }

    });

});

function sendCode(){
    $(".mobile-form").data('bootstrapValidator').validateField('phone');
    if(!$(".mobile-form").data('bootstrapValidator').isValidField("phone"))
    {
        return;
    }

    if(countdown != 60)
    {
        return;
    }
    sendmsg();
    var phone = $.trim($("#phone").val());
    var tenantId = $("#tenantId").val();
    $.ajax({
        //請(qǐng)求方式
        type : "POST",
        //請(qǐng)求的媒體類(lèi)型
        contentType: "application/x-www-form-urlencoded;charset=UTF-8",
        dataType: 'json',
        //請(qǐng)求地址
        url : "/code/sms/login",
        //數(shù)據(jù),json字符串
        data : {
            tenantId: tenantId,
            phoneNumber: phone,
            code: "aliValidateLogin"
        },
        //請(qǐng)求成功
        success : function(result) {
            $("#smsId").val(result.data);
        },
        //請(qǐng)求失敗绞幌,包含具體的錯(cuò)誤信息
        error : function(e){
            console.log(e);
        }
    });
};

function sendmsg(){
    if(countdown==0){
        $("#sendBtn").css("color","#181818");
        $("#sendBtn").html("獲取驗(yàn)證碼");
        countdown=60;
        return false;
    }
    else{
        $("#sendBtn").css("color","#74777b");
        $("#sendBtn").html("重新發(fā)送("+countdown+")");
        countdown--;
    }
    setTimeout(function(){
        sendmsg();
    },1000);
}

2蕾哟、授權(quán)服務(wù)器配置
  • 修改web安全配置WebSecurityConfig,將靜態(tài)文件添加到不需要授權(quán)就能訪(fǎng)問(wèn)
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
    }
  • 修改Nacos配置莲蜘,將新增頁(yè)面訪(fǎng)問(wèn)路徑添加到訪(fǎng)問(wèn)白名單谭确,使資源服務(wù)器配置ResourceServerConfig中的配置不進(jìn)行鑒權(quán)就能夠訪(fǎng)問(wèn),同時(shí)增加tokenUrls配置票渠,此配置在網(wǎng)關(guān)不進(jìn)行鑒權(quán)逐哈,但是需要OAuth2進(jìn)行Basic鑒權(quán),授權(quán)碼模式必須要用到此鑒權(quán)问顷。
# 以下配置為新增
  whiteUrls:
    - "/gitegg-oauth/oauth/login"
    - "/gitegg-oauth/oauth/find/pwd"
    - "/gitegg-oauth/oauth/error"
  authUrls:
    - "/gitegg-oauth/oauth/index"
  whiteUrls:
    - "/*/v2/api-docs"
    - "/gitegg-oauth/oauth/public_key"
    - "/gitegg-oauth/oauth/token_key"
    - "/gitegg-oauth/find/pwd"
    - "/gitegg-oauth/code/sms/login"
    - "/gitegg-oauth/change/password"
    - "/gitegg-oauth/error"
    - "/gitegg-oauth/oauth/sms/captcha/send"
  # 新增OAuth2認(rèn)證接口昂秃,此處網(wǎng)關(guān)放行,由認(rèn)證中心進(jìn)行認(rèn)證
  tokenUrls:
    - "/gitegg-oauth/oauth/token"
  • 因GitEgg框架使用用戶(hù)名+密碼再加密存儲(chǔ)的密碼杜窄,所以這里需要自定義登錄過(guò)濾器來(lái)做相應(yīng)處理肠骆,也可以用同樣的方式新增手機(jī)驗(yàn)證碼登錄、掃碼登錄等功能塞耕。
package com.gitegg.oauth.filter;

import cn.hutool.core.bean.BeanUtil;
import com.gitegg.oauth.token.PhoneAuthenticationToken;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.domain.GitEggUser;
import com.gitegg.platform.base.result.Result;
import com.gitegg.service.system.client.feign.IUserFeign;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 自定義登陸
 * @author GitEgg
 */
public class GitEggLoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public static final String SPRING_SECURITY_RESTFUL_TYPE_PHONE = "phone";

    public static final String SPRING_SECURITY_RESTFUL_TYPE_QR = "qr";

    public static final String SPRING_SECURITY_RESTFUL_TYPE_DEFAULT = "user";

    //  登陸類(lèi)型:user:用戶(hù)密碼登陸蚀腿;phone:手機(jī)驗(yàn)證碼登陸;qr:二維碼掃碼登陸
    private static final String SPRING_SECURITY_RESTFUL_TYPE_KEY = "type";

    //  登陸終端:1:移動(dòng)端登陸扫外,包括微信公眾號(hào)唯咬、小程序等纱注;0:PC后臺(tái)登陸
    private static final String SPRING_SECURITY_RESTFUL_MOBILE_KEY = "mobile";

    private static final String SPRING_SECURITY_RESTFUL_USERNAME_KEY = "username";

    private static final String SPRING_SECURITY_RESTFUL_PASSWORD_KEY = "password";

    private static final String SPRING_SECURITY_RESTFUL_PHONE_KEY = "phone";

    private static final String SPRING_SECURITY_RESTFUL_VERIFY_CODE_KEY = "code";

    private static final String SPRING_SECURITY_RESTFUL_QR_CODE_KEY = "qrCode";

    @Autowired
    private IUserFeign userFeign;

    private boolean postOnly = true;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        if (postOnly && !"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String type = obtainParameter(request, SPRING_SECURITY_RESTFUL_TYPE_KEY);
        String mobile = obtainParameter(request, SPRING_SECURITY_RESTFUL_MOBILE_KEY);
        AbstractAuthenticationToken authRequest;
        String principal;
        String credentials;

        // 手機(jī)驗(yàn)證碼登陸
        if(SPRING_SECURITY_RESTFUL_TYPE_PHONE.equals(type)){
            principal = obtainParameter(request, SPRING_SECURITY_RESTFUL_PHONE_KEY);
            credentials = obtainParameter(request, SPRING_SECURITY_RESTFUL_VERIFY_CODE_KEY);

            principal = principal.trim();
            authRequest = new PhoneAuthenticationToken(principal, credentials);
        }
        // 賬號(hào)密碼登陸
        else {
            principal = obtainParameter(request, SPRING_SECURITY_RESTFUL_USERNAME_KEY);
            credentials = obtainParameter(request, SPRING_SECURITY_RESTFUL_PASSWORD_KEY);

            Result<Object> result = userFeign.queryUserByAccount(principal);
            if (null != result && result.isSuccess()) {
                GitEggUser gitEggUser = new GitEggUser();
                BeanUtil.copyProperties(result.getData(), gitEggUser, false);
                if (!StringUtils.isEmpty(gitEggUser.getAccount())) {
                    principal = gitEggUser.getAccount();
                    credentials = AuthConstant.BCRYPT + gitEggUser.getAccount() + credentials;
                }
            }
            authRequest = new UsernamePasswordAuthenticationToken(principal, credentials);
        }

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    private void setDetails(HttpServletRequest request,
                            AbstractAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    private String obtainParameter(HttpServletRequest request, String parameter) {
        String result =  request.getParameter(parameter);
        return result == null ? "" : result;
    }
}

四、實(shí)現(xiàn)單點(diǎn)登錄客戶(hù)端

?? spring-security-oauth2提供OAuth2授權(quán)服務(wù)器的同時(shí)也提供了單點(diǎn)登錄客戶(hù)端的實(shí)現(xiàn)胆胰,通用通過(guò)幾行注解即可實(shí)現(xiàn)單點(diǎn)登錄功能狞贱。
1、新建單點(diǎn)登錄客戶(hù)端工程蜀涨,引入oauth2客戶(hù)端相關(guān)jar包

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
        </dependency>

2瞎嬉、新建WebSecurityConfig類(lèi),添加@EnableOAuth2Sso注解

@EnableOAuth2Sso
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    .csrf().disable();
    }
}

3厚柳、配置單點(diǎn)登錄服務(wù)端相關(guān)信息

server:
  port: 8080
  servlet:
    context-path: /ssoclient1
security:
  oauth2:
    client:
      # 配置在授權(quán)服務(wù)器配置的客戶(hù)端id和secret
      client-id: ssoclient
      client-secret: 123456
      # 獲取token的url
      access-token-uri: http://127.0.0.1/gitegg-oauth/oauth/token
      # 授權(quán)服務(wù)器的授權(quán)地址
      user-authorization-uri: http://127.0.0.1/gitegg-oauth/oauth/authorize
    resource:
      jwt:
        # 獲取公鑰的地址氧枣,驗(yàn)證token需使用,系統(tǒng)啟動(dòng)時(shí)會(huì)初始化别垮,不會(huì)每次驗(yàn)證都請(qǐng)求
        key-uri: http://127.0.0.1/gitegg-oauth/oauth/token_key

備注:

1便监、GitEgg框架中自定義了token返回格式,SpringSecurity獲取token的/oauth/token默認(rèn)返回的是ResponseEntity<OAuth2AccessToken>碳想,自有系統(tǒng)登錄和單點(diǎn)登錄時(shí)需要做轉(zhuǎn)換處理烧董。

2、Gateway網(wǎng)關(guān)鑒權(quán)需要的公鑰地址是gitegg-oauth/oauth/public_key胧奔,單點(diǎn)登錄客戶(hù)端需要公鑰地址
/oauth/token_key逊移,兩者返回的格式不一樣,需注意區(qū)分龙填。

3胳泉、請(qǐng)求/oauth/tonen和/oauth/token_key時(shí),默認(rèn)都需要使用Basic認(rèn)證岩遗,也就是請(qǐng)求時(shí)需添加client_id和client_security參數(shù)扇商。

GitEgg-Cloud是一款基于SpringCloud整合搭建的企業(yè)級(jí)微服務(wù)應(yīng)用開(kāi)發(fā)框架,開(kāi)源項(xiàng)目地址:

Gitee: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg

歡迎感興趣的小伙伴Star支持一下宿礁。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末案铺,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子窘拯,更是在濱河造成了極大的恐慌红且,老刑警劉巖坝茎,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件涤姊,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡嗤放,警方通過(guò)查閱死者的電腦和手機(jī)思喊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)次酌,“玉大人恨课,你說(shuō)我怎么就攤上這事舆乔。” “怎么了剂公?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵希俩,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我纲辽,道長(zhǎng)颜武,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任拖吼,我火速辦了婚禮鳞上,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘吊档。我一直安慰自己篙议,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布怠硼。 她就那樣靜靜地躺著鬼贱,像睡著了一般。 火紅的嫁衣襯著肌膚如雪拒名。 梳的紋絲不亂的頭發(fā)上吩愧,一...
    開(kāi)封第一講書(shū)人閱讀 51,688評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音增显,去河邊找鬼雁佳。 笑死,一個(gè)胖子當(dāng)著我的面吹牛同云,可吹牛的內(nèi)容都是我干的糖权。 我是一名探鬼主播,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼炸站,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼星澳!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起旱易,我...
    開(kāi)封第一講書(shū)人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤禁偎,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后阀坏,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體如暖,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年忌堂,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了盒至。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖枷遂,靈堂內(nèi)的尸體忽然破棺而出樱衷,到底是詐尸還是另有隱情,我是刑警寧澤酒唉,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布矩桂,位于F島的核電站,受9級(jí)特大地震影響痪伦,放射性物質(zhì)發(fā)生泄漏耍鬓。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一流妻、第九天 我趴在偏房一處隱蔽的房頂上張望牲蜀。 院中可真熱鬧,春花似錦绅这、人聲如沸涣达。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)度苔。三九已至,卻和暖如春浑度,著一層夾襖步出監(jiān)牢的瞬間寇窑,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工箩张, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留甩骏,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓先慷,卻偏偏與公主長(zhǎng)得像饮笛,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子论熙,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容