32 圖 | 手摸手 Spring Cloud Gateway + JWT 實(shí)現(xiàn)登錄認(rèn)證

目錄
通過(guò)本文你會(huì)掌握以下知識(shí)點(diǎn):
- 如何用認(rèn)證服務(wù)做登錄認(rèn)證。
- 如何生成 JWT 令牌(Token)
- 如何用 Gateway 對(duì) Token 驗(yàn)證。
- Gateway 如何從 Token 中拿到用戶信息并轉(zhuǎn)發(fā)給業(yè)務(wù)服務(wù)。
- 業(yè)務(wù)服務(wù)如何從請(qǐng)求中拿到身份信息處理業(yè)務(wù)邏輯。
- 如何刷新令牌。
本篇還是基于我的開(kāi)源項(xiàng)目 PassJava 作為講解。
PassJava 開(kāi)源地址:https://github.com/Jackson0714/PassJava-Platform
在講解之前有必要澄清下什么是認(rèn)證、授權(quán)、憑證,這三個(gè)方面是一個(gè)系統(tǒng)中最基礎(chǔ)的安全設(shè)計(jì)。
認(rèn)證、授權(quán)、憑證
1.1 認(rèn)證(Authentication)
認(rèn)證表示你是誰(shuí)。系統(tǒng)如何正確分辨出操作用戶的真實(shí)身份,比如通過(guò)輸入用戶名和密碼來(lái)辨別身份。
1.2 授權(quán)(Authorization)
授權(quán)表示你能干什么。系統(tǒng)如何控制一個(gè)用戶能看到哪些數(shù)據(jù)和操作哪些功能,也就是具有哪些權(quán)限。
1.3 憑證(Credential)
表示你如何證明你的身份。系統(tǒng)如何保證它與用戶之間的承諾是雙方當(dāng)時(shí)真實(shí)意圖的體現(xiàn),是準(zhǔn)確、完整和不可抵賴的。
接下來(lái)我們看下使用 JWT 作為憑證完成認(rèn)證的原理。
認(rèn)證的原理
在如下的認(rèn)證時(shí)序圖中,有以下幾種角色:
- ?客戶端:表示 APP 端或 PC 端的前端頁(yè)面。
- 網(wǎng)關(guān):表示 Spring Cloud Gateway 網(wǎng)關(guān)服務(wù),這里。
- 認(rèn)證服務(wù):用來(lái)接收客戶的登錄請(qǐng)求、登出請(qǐng)求、刷新令牌的操作。
- 業(yè)務(wù)服務(wù):和系統(tǒng)業(yè)務(wù)相關(guān)的微服務(wù)。
認(rèn)證和校驗(yàn)身份的流程如下所示:

認(rèn)證和校驗(yàn)身份流程
- ① 用戶登錄:客戶端在登錄頁(yè)面輸入用戶名和密碼,提交表單,調(diào)用登錄接口。
- ② 轉(zhuǎn)發(fā)請(qǐng)求:這里會(huì)先將登錄請(qǐng)求發(fā)送到網(wǎng)關(guān)服務(wù) passjava-gateway,網(wǎng)關(guān)對(duì)于登錄請(qǐng)求會(huì)直接轉(zhuǎn)發(fā)到認(rèn)證服務(wù) passjava-auth。(網(wǎng)關(guān)對(duì)登錄請(qǐng)求不做 token 校驗(yàn),這個(gè)可以配置不校驗(yàn)?zāi)男┱?qǐng)求 URL)
- ③ 認(rèn)證:認(rèn)證服務(wù)會(huì)將請(qǐng)求參數(shù)中的用戶名+密碼和數(shù)據(jù)庫(kù)中的用戶進(jìn)行比對(duì),如果完全匹配,則認(rèn)證通過(guò)。
- ④ 生成令牌:生成兩個(gè)令牌:access_token 和 refresh_token(刷新令牌),刷新令牌我們后面再說(shuō),這里其實(shí)也可以只用生成一個(gè)令牌 access_token。令牌里面會(huì)包含用戶的身份信息,如果要做權(quán)限管控,還需要在 token 里面包含用戶的權(quán)限信息,權(quán)限這一塊不在本篇展開(kāi),會(huì)放到下一篇中進(jìn)行講解。
- ⑤ 客戶端緩存 token:客戶端拿到兩個(gè) token 緩存到 cookie 中或者 LocalStorage 中。
- ⑥ 攜帶 token 發(fā)起請(qǐng)求:客戶端下次想調(diào)用業(yè)務(wù)服務(wù)時(shí),將 access_token 放到請(qǐng)求的 header 中。
- ⑦ 網(wǎng)關(guān)校驗(yàn) token:請(qǐng)求還是先到網(wǎng)關(guān)服務(wù),然后由它校驗(yàn) access_token 是否合法。如果 access_token 未過(guò)期,且能正確解析出來(lái),就說(shuō)明是合法的 access_token。
- ⑧ 攜帶用戶身份信息轉(zhuǎn)發(fā)請(qǐng)求:網(wǎng)關(guān)將 access_token 中攜帶的用戶的 user_id 放到請(qǐng)求的 header 中,轉(zhuǎn)發(fā)給真正的業(yè)務(wù)服務(wù)。
- ⑨ 處理業(yè)務(wù)邏輯:業(yè)務(wù)服務(wù)從 header 中拿到用戶的 user_id,然后處理業(yè)務(wù)邏輯,處理完后將結(jié)果原路返回給客戶端。
- 接下來(lái)我們看下項(xiàng)目的整體架構(gòu)。
項(xiàng)目整體結(jié)構(gòu)
Github 項(xiàng)目地址:https://github.com/Jackson0714/PassJava-Platform
Gitee 項(xiàng)目地址:https://toscode.gitee.com/jayh2018/PassJava-Platform
- 認(rèn)證服務(wù):passjava-auth
- 網(wǎng)關(guān)服務(wù):passjava-gateway
- JWT 公共項(xiàng)目:passjava-jwt,認(rèn)證服務(wù)和網(wǎng)關(guān)服務(wù)都會(huì)引用這個(gè)公共項(xiàng)目。
- 業(yè)務(wù)服務(wù):passjava-member,會(huì)員服務(wù)作為本次案例的業(yè)務(wù)服務(wù)。
- Nacos 注冊(cè)配置中心

PassJava-Platform 框架
認(rèn)證服務(wù):passjava-auth
passjava-auth 服務(wù)
核心類(lèi)就是 JwtAuthController 類(lèi),里面有登錄接口和刷新令牌的接口。
網(wǎng)關(guān)服務(wù):passjava-gateway

passjava-gateway 服務(wù)
核心類(lèi)就是 JwtAuthCheckFilter 全局過(guò)濾器。
如果不需要在服務(wù)端保存刷新令牌,可以不需要 redis 配置。
JWT 公共項(xiàng)目

passjava-jwt 服務(wù)
核心類(lèi)就是 PassJavaJWTTokenUtil 工具類(lèi)。認(rèn)證服務(wù)引入 JWT 項(xiàng)目后用來(lái)生成 token,網(wǎng)關(guān)服務(wù)引入 JWT 項(xiàng)目后用來(lái)校驗(yàn) token 合法性。
業(yè)務(wù)服務(wù)
這里我選擇了會(huì)員微服務(wù)作為本次演示的業(yè)務(wù)微服務(wù)。
它從網(wǎng)關(guān)轉(zhuǎn)發(fā)的請(qǐng)求 Header 中拿到 userId, 根據(jù) userId 查詢 member 信息。

passjava-member 服務(wù)
核心文件是 MemberController 類(lèi)、MemberEntity實(shí)體類(lèi)、MemberService服務(wù)類(lèi)、MemberDao 類(lèi)和 mapper 文件。
啟動(dòng)的服務(wù)
Nacos 注冊(cè)配置中心
首先啟動(dòng) Nacos 服務(wù)。和 PassJava 項(xiàng)目配套使用的 Nacos 工具已經(jīng)上傳到網(wǎng)盤(pán),下載后直接運(yùn)行啟動(dòng)腳本就可以將 Nacos 在本地啟動(dòng)。
啟動(dòng)教程:
www.passjava.cn/#/01.項(xiàng)目簡(jiǎn)介/7.本地部署項(xiàng)目Mac版
網(wǎng)關(guān)、會(huì)員、認(rèn)證服務(wù)
啟動(dòng)以下三個(gè)微服務(wù),分別為網(wǎng)關(guān)、會(huì)員、認(rèn)證服務(wù)。

檢查下 nacos 注冊(cè)中心上是否注冊(cè)了這三個(gè)服務(wù):可以看到確實(shí)有上面的三個(gè)微服務(wù)。

如何做登錄認(rèn)證
登錄認(rèn)證就是校驗(yàn)下用戶提交的賬戶名和密碼與本地?cái)?shù)據(jù)庫(kù)中的是否完全匹配,如果匹配,就認(rèn)證通過(guò)。就是下方這個(gè)流程的 1、2、3 步。

第一步:提交用戶名和密碼
這里用 Postman 工具模擬前端發(fā)起登錄請(qǐng)求,請(qǐng)求的 URL 如下:
http://localhost:8060/api/auth/login

請(qǐng)求是向網(wǎng)關(guān)服務(wù) passjava-gateway 發(fā)起的,所以可以看到上面的 URL 中 localhost 和 8060 是網(wǎng)關(guān)的 host 和 port。
然后 API 地址為 /api/auth/login,這個(gè)地址經(jīng)過(guò)網(wǎng)關(guān)的路由匹配后會(huì)轉(zhuǎn)發(fā)到 passjava-auth 服務(wù)的登錄 API。
http://localhost:10001/auth/login
關(guān)于網(wǎng)關(guān)轉(zhuǎn)發(fā)的原理可以參考這篇:深入理解 Spring Cloud Gateway 的原理
請(qǐng)求參數(shù)如下:
{
"userId": "wukong",
"password": "123456"
}
賬號(hào)和密碼都是密文的,轉(zhuǎn)發(fā)到認(rèn)證服務(wù)后,會(huì)根據(jù) userId 查詢出系統(tǒng)用戶,然后將 password 參數(shù)加密后對(duì)比系統(tǒng)用戶的密碼。
所以為了讓用戶登錄成功,還需要在數(shù)據(jù)庫(kù)插入一條系統(tǒng)用戶,用戶 id 為 wukong,密碼是對(duì) 123456 加密后的密碼。

在線加密工具地址:
https://www.bejson.com/encrypt/bcrpyt_encode/
第二步:轉(zhuǎn)發(fā)登錄請(qǐng)求
轉(zhuǎn)發(fā)登錄請(qǐng)求是網(wǎng)關(guān)服務(wù)做的,所以我們來(lái)看下做了哪些事情。
在 Gateway 項(xiàng)目的 application-routers.yml 中配置路由規(guī)則:
spring:
cloud:
gateway:
routes:
- id: route_auth # 認(rèn)證微服務(wù)路由規(guī)則
uri: lb://passjava-auth # 負(fù)載均衡,將請(qǐng)求轉(zhuǎn)發(fā)到注冊(cè)中心注冊(cè)的 passjava-auth 服務(wù)
predicates: # 斷言
- Path=/api/auth/** # 如果前端請(qǐng)求路徑包含 api/auth,則應(yīng)用這條路由規(guī)則
filters: #過(guò)濾器
- RewritePath=/api/(?<segment>.*),/$\{segment} # 將跳轉(zhuǎn)路徑中包含的api替換成空
在 application.properties 引入 application-routers.yml
spring:
profiles:
include: routers, jwt
第三步:驗(yàn)證用戶賬號(hào)和密碼
這一步是認(rèn)證服務(wù)的登錄 API 里面做的。在 AuthController 中定義 login 接口,核心步驟就是查找系統(tǒng)用戶和比對(duì)密碼。

登錄 API
用戶名和密碼匹配成功后,就會(huì)生成 JWT 令牌。
如何生成令牌
生成令牌就是通過(guò)工具類(lèi) PassJavaJwtTokenUtil 生成 JWT Token,也就是流程圖中的第四步。

流程圖-生成 JWT 令牌
生成令牌的核心代碼如下:

生成 JWT 的核心代碼
使用這個(gè)工具類(lèi)的前提是我們需要先引入 jjwt 依賴。這個(gè)在 passjava-jwt 項(xiàng)目的 pom 文件中引入。

引入 jjwt 依賴
用 Postman 工具調(diào)用后,可以看到生成的令牌如下:

生成令牌
用 base64 解碼后,可以看到 token 中的 PAYLOAD 里面包含了用戶 id 和用戶名。

生成 JWT 的加密密鑰一般都是寫(xiě)到配置文件中。這里我是配置在 passjava-jwt 項(xiàng)目的 application-jwt.yml 配置文件中的。

JWT 配置項(xiàng)
然后認(rèn)證服務(wù)就會(huì)將 JWT 令牌返回給客戶端了。當(dāng)客戶端想要查詢這個(gè) userId 對(duì)應(yīng)的會(huì)員信息時(shí),就可以在請(qǐng)求的 Header 中帶上 JWT 令牌。
如何攜帶 JWT 發(fā)送請(qǐng)求

客戶端(瀏覽器或 APP)拿到 JWT 后,可以將 JWT 存放在瀏覽器的 Cookie 或 LocalStorage(本地存儲(chǔ)) 或者內(nèi)存中。
發(fā)送請(qǐng)求時(shí)在請(qǐng)求 Header 的 Authorization 字段中設(shè)置 JWT,這個(gè)字段其實(shí)可以自定義,但是我建議用 Authorization,因?yàn)檫@是一種業(yè)界標(biāo)準(zhǔn)。
另外告訴大家一個(gè)小技巧,在 Postman 工具中有個(gè)地方專(zhuān)門(mén)配置 Authorization,然后自動(dòng)加到 Header 中,不用自己手動(dòng)加 Header。


還有一個(gè)點(diǎn)需要注意,這里配置的 Authorization 的認(rèn)證類(lèi)型為 Bearer Token。它表示令牌可以是任意字符串格式的令牌。然后會(huì)在 Authorization 字段中加上一個(gè)前綴 Bearer。所以我們?cè)诰W(wǎng)關(guān)服務(wù)解析 Header 中的 Authorization 時(shí),需要去掉這個(gè)前綴 Bearer,代碼如下所示:

去掉 Bearer 前綴
網(wǎng)關(guān)如何驗(yàn)證 JWT 和轉(zhuǎn)發(fā)請(qǐng)求

網(wǎng)關(guān)驗(yàn)證 Token和轉(zhuǎn)發(fā)請(qǐng)求
網(wǎng)關(guān)接收到前端發(fā)起的業(yè)務(wù)請(qǐng)求后,會(huì)先驗(yàn)證請(qǐng)求的 Header 中是否攜帶 Authorization 字段,以及里面的 Token 是否合法。然后解析 Token 中的 userId 和 username,放到 header 中再進(jìn)行轉(zhuǎn)發(fā),也就是流程圖中的第七步和第八步。
網(wǎng)關(guān)是通過(guò)多個(gè)??過(guò)濾器 Filter??對(duì)請(qǐng)求進(jìn)行串行攔截處理的,所以我們可以自定義一個(gè)全局過(guò)濾器,對(duì)所有請(qǐng)求進(jìn)行校驗(yàn),當(dāng)然對(duì)于一些特殊請(qǐng)求比如登錄請(qǐng)求就不需要校驗(yàn)了,因?yàn)檎{(diào)用登錄請(qǐng)求的時(shí)候還沒(méi)有生成 Token。
網(wǎng)關(guān)的全局過(guò)濾器 JwtAuthCheckFilter 的核心代碼如下所示:

網(wǎng)關(guān)的全局過(guò)濾器 JwtAuthCheckFilter
會(huì)員服務(wù)處理業(yè)務(wù)邏輯

會(huì)員服務(wù)接收到網(wǎng)關(guān)轉(zhuǎn)發(fā)的請(qǐng)求后,就從 Header 中拿到用戶身份信息,然后通過(guò) userId 獲取會(huì)員信息。
注意:有的時(shí)候業(yè)務(wù)邏輯并不需要身份信息,更多的時(shí)候是需要檢驗(yàn)用戶的操作權(quán)限是否足夠。其實(shí) Token 里面也是可以攜帶權(quán)限信息的,不過(guò)這是下一篇講解授權(quán)的部分。
獲取 userId 的方式其實(shí)可以通過(guò)加一個(gè)??攔截器??,由攔截器將 Header 中的 userId 和 username 放到線程中,后續(xù)的 controller,service,dao 類(lèi)都可以從線程里面拿到 userId 和 username,不用通過(guò)傳參的方式。
獲取 userId 的方式:
- 方式一:從 request 的 Header 中拿到 userId。代碼簡(jiǎn)單,但是如果其他地方也要用到 userId,則需要通過(guò)方法傳參的方式傳遞 userId。
- 方式二:從線程變量里面拿到 userId。代碼復(fù)雜,使用簡(jiǎn)單。好處是所有地方統(tǒng)一從一個(gè)地方獲取。
Request 中獲取 userId 方式
代碼示例如下:

下面介紹如何使用攔截器方式將 userId 存入線程變量的方式。
攔截器方式
在 passjava-common 模塊中新增一個(gè)攔截器,獲取請(qǐng)求頭中的身份信息,加入到線程變量中。文件名為 HeaderInterceptor。

將攔截器注冊(cè)到 WebMvcConfigurer。文件名為 WebMvcConfig.java。

配置文件中需要定義一個(gè)配置項(xiàng):
文件名;org.springframework.boot.autoconfigure.AutoConfiguration.imports
配置項(xiàng):com.jackson0714.passjava.common.config.WebMvcConfig
然后 passjava-member 服務(wù)引入這個(gè)攔截器配置。
@Import({WebMvcConfig.class})
通過(guò)上面兩種方式中的任意一種拿到 userId 后,通過(guò) userId 查詢會(huì)員的詳情。這里需要注意的是這個(gè) user 既是系統(tǒng)用戶也是系統(tǒng)中的會(huì)員。關(guān)于查詢會(huì)員的數(shù)據(jù)庫(kù)操作就不在此展開(kāi)了。
執(zhí)行結(jié)果如下圖所示:

如何刷新令牌
還有一個(gè)內(nèi)容是關(guān)于如何刷新令牌的。當(dāng)認(rèn)證服務(wù)返回給客戶端的 JWT 也就是 access_token 過(guò)期后,客戶端是通過(guò)發(fā)送登錄請(qǐng)求重新拿到 access_token 嗎?
這種重新登錄的操作如果很頻繁(因 JWT 過(guò)期時(shí)間較短),對(duì)于用戶來(lái)說(shuō)體驗(yàn)就很差了。客戶端需要跳轉(zhuǎn)到登錄頁(yè)面,讓用戶重新提交用戶名和密碼,即使客戶端有記住用戶名和密碼,但是這種跳轉(zhuǎn)到登錄頁(yè)的操作會(huì)大幅度降低用戶的體驗(yàn),甚至導(dǎo)致用戶不想再用第二次。
有沒(méi)有一種比較優(yōu)雅的方式讓客戶端重新拿到 access_token 或者說(shuō)延長(zhǎng) access_token 有效期呢?
我們知道 JWT 生成后是不能篡改里面的內(nèi)容,即使是 JWT 的有效期也不行。所以延長(zhǎng) access_token 有效期的做法并不適合,而且如果長(zhǎng)期保持一個(gè) access_token 有效,也是不安全的。
那就只能重新生成 access_token 了。方案其實(shí)挺簡(jiǎn)單,客戶端拿之前生成的 JWT 調(diào)用后端一個(gè)接口,然后后端校驗(yàn)這個(gè) JWT 是否合法,如果是合法的就重新生成一個(gè)新的返回給客戶端??蛻舳俗孕刑鎿Q掉之前本地保存的 access_token 就可以了。

生成 access_token 和 refresh_token
這里有一個(gè)巧妙的設(shè)計(jì),就是生成 JWT 時(shí),返回了兩個(gè) JWT token,一個(gè) access_token,一個(gè) refresh_token,這兩個(gè) token 其實(shí)都可以用來(lái)刷新 token,但是我們把 refresh_token 設(shè)置的過(guò)期時(shí)間稍微長(zhǎng)一點(diǎn),比如兩倍于 access_token,當(dāng) access_token 過(guò)期后,refresh_token 如果還沒(méi)有過(guò)期,就可以利用兩者的過(guò)期時(shí)間差進(jìn)行重新生成令牌的操作,也就是刷新令牌,這里的刷新指的是客戶端重置本地保存的令牌,以后都用新的令牌。
饑餓模式和懶模式
當(dāng)然,在 access_token 過(guò)期之前,客戶端提前刷新令牌也是可以的,我稱(chēng)這種提前刷新的模式為??饑餓模式???(單例模式中也有這種叫法),而過(guò)期后再刷新令牌的模式我稱(chēng)之為??懶模式??。兩種模式都可以用,前者需要客戶端定期檢查過(guò)期時(shí)間,增加了復(fù)雜性;后者則會(huì)出現(xiàn)短暫的請(qǐng)求失敗的情況,得拿到新的令牌后才會(huì)成功。
刷新令牌的操作完全是通過(guò)客戶端自己控制的,而且客戶端也不僅限于瀏覽器,還有可能是第三方服務(wù)。
一次性
通常情況下,我們會(huì)將刷新令牌 refresh_token 設(shè)置為只能用一次,來(lái)保證刷新令牌的安全性。而這種就需要服務(wù)端來(lái)緩存刷新令牌了,當(dāng)用過(guò)一次后,就從緩存里面主動(dòng)剔除掉。但這樣就違背了 JWT 無(wú)狀態(tài)的特性,這個(gè)完全看業(yè)務(wù)需求來(lái)決定是否使用這種緩存方式。
如下圖所示,生成令牌時(shí)我將刷新令牌緩存到了 Redis 里面。當(dāng)我用 refresh_token 調(diào)用刷新 API 時(shí),會(huì)主動(dòng)剔除掉這個(gè) key,下次再用相同的 refresh_token 刷新令牌時(shí),因 Redis 中不存在這個(gè) key,就會(huì)提示刷新刷新失敗了。

緩存令牌
留兩個(gè)小問(wèn)題:
- 有沒(méi)有辦法讓 access_token 主動(dòng)失效?
- 場(chǎng)景題:如何保證同一個(gè)用戶只能登錄一臺(tái)設(shè)備?
總結(jié)
雖然本篇是講實(shí)戰(zhàn)內(nèi)容的,但是里面又涉及了很多原理性內(nèi)容,比如網(wǎng)關(guān)、JWT 的原理。
結(jié)合實(shí)戰(zhàn)講解,相信大家對(duì)如何使用 Spring Cloud Gateway + JWT 實(shí)現(xiàn)登錄認(rèn)證有了充分的理解。
本篇只講解了認(rèn)證和憑證,授權(quán)部分還沒(méi)有觸及,所以這也是下篇要講解的內(nèi)容,來(lái)追更吧~
最后再說(shuō)一句,別白嫖,點(diǎn)贊轉(zhuǎn)發(fā)下哦~































