文章目錄
0.學習目標
1.無狀態登錄原理
1.1.什麼是有狀態?
有狀態服務,即服務端需要記錄每次會話的客戶端信息,從而識別客戶端身份,根據用戶身份進行請求的處理,典型的設計如tomcat中的session。
例如登錄:用戶登錄後,我們把登錄者的信息保存在服務端session中,並且給用戶一個cookie值,記錄對應的session。然後下次請求,用戶攜帶cookie值來,我們就能識別到對應session,從而找到用戶的信息。
缺點是什麼?
- 服務端保存大量數據,增加服務端壓力
- 服務端保存用戶狀態,無法進行水平擴展
- 客戶端請求依賴服務端,多次請求必須訪問同一臺服務器
1.2.什麼是無狀態
微服務集羣中的每個服務,對外提供的都是Rest風格的接口。而Rest風格的一個最重要的規範就是:服務的無狀態性,即:
- 服務端不保存任何客戶端請求者信息
- 客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份
帶來的好處是什麼呢?
- 客戶端請求不依賴服務端的信息,任何多次請求不需要必須訪問到同一臺服務
- 服務端的集羣和狀態對客戶端透明
- 服務端可以任意的遷移和伸縮
- 減小服務端存儲壓力
1.3.如何實現無狀態
無狀態登錄的流程:
- 當客戶端第一次請求服務時,服務端對用戶進行信息認證(登錄)
- 認證通過,將用戶信息進行加密形成token,返回給客戶端,作爲登錄憑證
- 以後每次請求,客戶端都攜帶認證的token
- 服務端對token進行解密,判斷是否有效。
流程圖:
整個登錄過程中,最關鍵的點是什麼?
token的安全性
token是識別客戶端身份的唯一標示,如果加密不夠嚴密,被人僞造那就完蛋了。
採用何種方式加密纔是安全可靠的呢?
我們將採用JWT + RSA非對稱加密
1.4.JWT
1.4.1.簡介
JWT,全稱是Json Web Token, 是JSON風格輕量級的授權和身份認證規範,可實現無狀態、分佈式的Web應用授權;官網:https://jwt.io
GitHub上jwt的java客戶端:https://github.com/jwtk/jjwt
1.4.2.數據格式
JWT包含三部分數據:
-
Header:頭部,通常頭部有兩部分信息:
- 聲明類型,這裏是JWT
- 加密算法,自定義
我們會對頭部進行base64加密(可解密),得到第一部分數據
-
Payload:載荷,就是有效數據,一般包含下面信息:
- 用戶身份信息(注意,這裏因爲採用base64加密,可解密,因此不要存放敏感信息)
- 註冊聲明:如token的簽發時間,過期時間,簽發人等
這部分也會採用base64加密,得到第二部分數據
-
Signature:簽名,是整個數據的認證信息。一般根據前兩步的數據,再加上服務的的密鑰(secret)(不要泄漏,最好週期性更換),通過加密算法生成。用於驗證整個數據完整和可靠性
生成的數據格式:
可以看到分爲3段,每段就是上面的一部分數據
1.4.3.JWT交互流程
流程圖:
步驟翻譯:
- 1、用戶登錄
- 2、服務的認證,通過後根據secret生成token
- 3、將生成的token返回給瀏覽器
- 4、用戶每次請求攜帶token
- 5、服務端利用公鑰解讀jwt簽名,判斷簽名有效後,從Payload中獲取用戶信息
- 6、處理請求,返回響應結果
因爲JWT簽發的token中已經包含了用戶的身份信息,並且每次請求都會攜帶,這樣服務的就無需保存用戶信息,甚至無需去數據庫查詢,完全符合了Rest的無狀態規範。
1.4.4.非對稱加密
加密技術是對信息進行編碼和解碼的技術,編碼是把原來可讀信息(又稱明文)譯成代碼形式(又稱密文),其逆過程就是解碼(解密),加密技術的要點是加密算法,加密算法可以分爲三類:
- 對稱加密,如AES
- 基本原理:將明文分成N個組,然後使用密鑰對各個組進行加密,形成各自的密文,最後把所有的分組密文進行合併,形成最終的密文。
- 優勢:算法公開、計算量小、加密速度快、加密效率高
- 缺陷:雙方都使用同樣密鑰,安全性得不到保證
- 非對稱加密,如RSA
- 基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱祕保存,公鑰可以下發給信任客戶端
- 私鑰加密,持有私鑰或公鑰纔可以解密
- 公鑰加密,持有私鑰纔可解密
- 優點:安全,難以破解
- 缺點:算法比較耗時
- 基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱祕保存,公鑰可以下發給信任客戶端
- 不可逆加密,如MD5,SHA
- 基本原理:加密過程中不需要使用密鑰,輸入明文後由系統直接經過加密算法處理成密文,這種加密後的數據是無法被解密的,無法根據密文推算出明文。
RSA算法歷史:
1977年,三位數學家Rivest、Shamir 和 Adleman 設計了一種算法,可以實現非對稱加密。這種算法用他們三個人的名字縮寫:RSA
1.5.結合Zuul的鑑權流程
我們逐步演進系統架構設計。需要注意的是:secret是簽名的關鍵,因此一定要保密,我們放到鑑權中心保存,其它任何服務中都不能獲取secret。
1.5.1.沒有RSA加密時
在微服務架構中,我們可以把服務的鑑權操作放到網關中,將未通過鑑權的請求直接攔截,如圖:
- 1、用戶請求登錄
- 2、Zuul將請求轉發到授權中心,請求授權
- 3、授權中心校驗完成,頒發JWT憑證
- 4、客戶端請求其它功能,攜帶JWT
- 5、Zuul將jwt交給授權中心校驗,通過後放行
- 6、用戶請求到達微服務
- 7、微服務將jwt交給鑑權中心,鑑權同時解析用戶信息
- 8、鑑權中心返回用戶數據給微服務
- 9、微服務處理請求,返回響應
發現什麼問題了?
每次鑑權都需要訪問鑑權中心,系統間的網絡請求頻率過高,效率略差,鑑權中心的壓力較大。
1.5.2.結合RSA的鑑權
直接看圖:
- 我們首先利用RSA生成公鑰和私鑰。私鑰保存在授權中心,公鑰保存在Zuul和各個微服務
- 用戶請求登錄
- 授權中心校驗,通過後用私鑰對JWT進行簽名加密
- 返回jwt給用戶
- 用戶攜帶JWT訪問
- Zuul直接通過公鑰解密JWT,進行驗證,驗證通過則放行
- 請求到達微服務,微服務直接用公鑰解析JWT,獲取用戶信息,無需訪問授權中心
2.授權中心
2.1.創建授權中心
授權中心的主要職責:
- 用戶鑑權:
- 接收用戶的登錄請求,通過用戶中心的接口進行校驗,通過後生成JWT
- 使用私鑰生成JWT並返回
- 服務鑑權:微服務間的調用不經過Zuul,會有風險,需要鑑權中心進行認證
- 原理與用戶鑑權類似,但邏輯稍微複雜一些(此處我們不做實現)
因爲生成jwt,解析jwt這樣的行爲以後在其它微服務中也會用到,因此我們會抽取成工具。我們把鑑權中心進行聚合,一個工具module,一個提供服務的module
2.1.1.創建父module
我們先創建父module,名稱爲:leyou-auth
將pom打包方式改爲pom:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth</artifactId>
<version>1.0.0-SNAPSHOT</version>
</project>
2.1.2.通用module
然後是授權服務的通用模塊:leyou-auth-common:
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou-auth</artifactId>
<groupId>com.leyou.auth</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</project>
結構:
2.1.3.授權服務
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou-auth</artifactId>
<groupId>com.leyou.auth</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
引導類:
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LeyouAuthApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouAuthApplication.class, args);
}
}
application.yml
server:
port: 8087
spring:
application:
name: auth-service
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
registry-fetch-interval-seconds: 10
instance:
lease-renewal-interval-in-seconds: 5 # 每隔5秒發送一次心跳
lease-expiration-duration-in-seconds: 10 # 10秒不發送就過期
prefer-ip-address: true
ip-address: 127.0.0.1
instance-id: ${spring.application.name}:${server.port}
結構:
在leyou-gateway工程的application.yml中,修改路由:
zuul:
prefix: /api # 路由路徑前綴
routes:
item-service: /item/** # 商品微服務的映射路徑
search-service: /search/** # 搜索微服務
user-service: /user/** # 用戶微服務
auth-service: /auth/** # 授權中心微服務
2.2.JWT工具類
我們在leyou-auth-common
中導入課前資料中的工具類:
需要在leyou-auth-common
中引入JWT依賴:
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
</dependencies>
2.3.測試工具類
我們在leyou-auth-common
中編寫測試類:
public class JwtTest {
private static final String pubKeyPath = "C:\\tmp\\rsa\\rsa.pub";
private static final String priKeyPath = "C:\\tmp\\rsa\\rsa.pri";
private PublicKey publicKey;
private PrivateKey privateKey;
@Test
public void testRsa() throws Exception {
RsaUtils.generateKey(pubKeyPath, priKeyPath, "234");
}
@Before
public void testGetRsa() throws Exception {
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
}
@Test
public void testGenerateToken() throws Exception {
// 生成token
String token = JwtUtils.generateToken(new UserInfo(20L, "jack"), privateKey, 5);
System.out.println("token = " + token);
}
@Test
public void testParseToken() throws Exception {
String token = "eyJhbGciOiJSUzI1NiJ9.eyJpZCI6MjAsInVzZXJuYW1lIjoiamFjayIsImV4cCI6MTUzMzI4MjQ3N30.EPo35Vyg1IwZAtXvAx2TCWuOPnRwPclRNAM4ody5CHk8RF55wdfKKJxjeGh4H3zgruRed9mEOQzWy79iF1nGAnvbkraGlD6iM-9zDW8M1G9if4MX579Mv1x57lFewzEo-zKnPdFJgGlAPtNWDPv4iKvbKOk1-U7NUtRmMsF1Wcg";
// 解析token
UserInfo user = JwtUtils.getInfoFromToken(token, publicKey);
System.out.println("id: " + user.getId());
System.out.println("userName: " + user.getUsername());
}
}
測試生成公鑰和私鑰,我們運行這段代碼:
運行之後,查看目標目錄:
公鑰和私鑰已經生成了!
測試生成token,把@Before的註釋去掉的:
測試解析token:
正常情況:
任意改動token,發現報錯了:
2.3.編寫登錄授權接口
接下來,我們需要在leyou-auth-servcice
編寫一個接口,對外提供登錄授權服務。基本流程如下:
- 客戶端攜帶用戶名和密碼請求登錄
- 授權中心調用客戶中心接口,根據用戶名和密碼查詢用戶信息
- 如果用戶名密碼正確,能獲取用戶,否則爲空,則登錄失敗
- 如果校驗成功,則生成JWT並返回
2.3.1.生成公鑰和私鑰
我們需要在授權中心生成真正的公鑰和私鑰。我們必須有一個生成公鑰和私鑰的secret,這個可以配置到application.yml
中:
leyou:
jwt:
secret: leyou@Login(Auth}*^31)&heiMa% # 登錄校驗的密鑰
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公鑰地址
priKeyPath: C:\\tmp\\rsa\\rsa.pri # 私鑰地址
expire: 30 # 過期時間,單位分鐘
然後編寫屬性類,加載這些數據:
@ConfigurationProperties(prefix = "ly.jwt")
public class JwtProperties {
private String secret; // 密鑰
private String pubKeyPath;// 公鑰
private String priKeyPath;// 私鑰
private int expire;// token過期時間
private PublicKey publicKey; // 公鑰
private PrivateKey privateKey; // 私鑰
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
/**
* @PostContruct:在構造方法執行之後執行該方法
*/
@PostConstruct
public void init(){
try {
File pubKey = new File(pubKeyPath);
File priKey = new File(priKeyPath);
if (!pubKey.exists() || !priKey.exists()) {
// 生成公鑰和私鑰
RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);
}
// 獲取公鑰和私鑰
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
} catch (Exception e) {
logger.error("初始化公鑰和私鑰失敗!", e);
throw new RuntimeException();
}
}
// getter setter ...
}
2.3.2.Controller
編寫授權接口,我們接收用戶名和密碼,校驗成功後,寫入cookie中。
- 請求方式:post
- 請求路徑:/accredit
- 請求參數:username和password
- 返回結果:無
代碼:
@RestController
@EnableConfigurationProperties(JwtProperties.class)
public class AuthController {
@Autowired
private AuthService authService;
@Autowired
private JwtProperties prop;
/**
* 登錄授權
*
* @param username
* @param password
* @return
*/
@PostMapping("accredit")
public ResponseEntity<Void> authentication(
@RequestParam("username") String username,
@RequestParam("password") String password,
HttpServletRequest request,
HttpServletResponse response) {
// 登錄校驗
String token = this.authService.authentication(username, password);
if (StringUtils.isBlank(token)) {
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
}
// 將token寫入cookie,並指定httpOnly爲true,防止通過JS獲取和修改
CookieUtils.setCookie(request, response, prop.getCookieName(),
token, prop.getCookieMaxAge(), null, true);
return ResponseEntity.ok().build();
}
}
這裏的cookie的name和生存時間,我們配置到屬性文件:application.yml:
然後在JwtProperties
中添加屬性:
2.3.3.CookieUtils
要注意,這裏我們使用了一個工具類,CookieUtils,可以在課前資料中找到,我們把它添加到leyou-common
中,然後引入servlet相關依賴即可:
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
</dependency>
代碼:略
2.3.3.UserClient
接下來我們肯定要對用戶密碼進行校驗,所以我們需要通過FeignClient去訪問 user-service微服務:
引入user-service依賴:
<dependency>
<groupId>com.leyou.user</groupId>
<artifactId>leyou-user-interface</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
編寫FeignClient:
@FeignClient(value = "user-service")
public interface UserClient extends UserApi {
}
在leyou-user-interface工程中添加api接口:
內容:
@RequestMapping("user")
public interface UserApi {
@GetMapping("query")
public User queryUser(
@RequestParam("username") String username,
@RequestParam("password") String password);
}
2.3.4.Service
@Service
public class AuthService {
@Autowired
private UserClient userClient;
@Autowired
private JwtProperties properties;
public String authentication(String username, String password) {
try {
// 調用微服務,執行查詢
User user = this.userClient.queryUser(username, password);
// 如果查詢結果爲null,則直接返回null
if (user == null) {
return null;
}
// 如果有查詢結果,則生成token
String token = JwtUtils.generateToken(new UserInfo(user.getId(), user.getUsername()),
properties.getPrivateKey(), properties.getExpire());
return token;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
2.3.5.項目結構
2.3.6.測試
2.4.登錄頁面
接下來,我們看看登錄頁面,是否能夠正確的發出請求。
我們在頁面輸入登錄信息,然後點擊登錄:
查看控制檯:
發現請求的路徑不對,我們的認證接口是:
/api/auth/accredit
我們打開login.html,修改路徑信息:
頁面ajax請求:
然後再次測試,成功跳轉到了首頁:
2.5.解決cookie寫入問題
接下來我們查看首頁cookie:
什麼都沒有,爲什麼?
2.5.1.問題分析
我們在之前測試時,清晰的看到了響應頭中,有Set-Cookie屬性,爲什麼在這裏卻什麼都沒有?
我們之前在講cors跨域時,講到過跨域請求cookie生效的條件:
- 服務的響應頭中需要攜帶Access-Control-Allow-Credentials並且爲true。
- 響應頭中的Access-Control-Allow-Origin一定不能爲*,必須是指定的域名
- 瀏覽器發起ajax需要指定withCredentials 爲true
看看我們的服務端cors配置:
沒有任何問題。
再看客戶端瀏覽器的ajax配置,我們在js/common.js
中對axios進行了統一配置:
一切OK。
那說明,問題一定出在響應的set-cookie頭中。我們再次仔細看看剛纔的響應頭:
我們發現cookie的 domain
屬性似乎不太對。
cookie也是有域
的限制,一個網頁,只能操作當前域名下的cookie,但是現在我們看到的地址是0.0.1,而頁面是www.leyou.com,域名不匹配,cookie設置肯定失敗了!
2.5.2.跟蹤CookieUtils
我們去Debug跟蹤CookieUtils,看看到底是怎麼回事:
我們發現內部有一個方法,用來獲取Domain:
它獲取domain是通過服務器的host來計算的,然而我們的地址竟然是:127.0.0.1:8087,因此後續的運算,最終得到的domain就變成了:
問題找到了:我們請求時的serverName明明是:api.leyou.com,現在卻被變成了:127.0.0.1,因此計算domain是錯誤的,從而導致cookie設置失敗!
2.5.3.解決host地址的變化
那麼問題來了:爲什麼我們這裏的請求serverName變成了:127.0.0.1:8087呢?
這裏的server name其實就是請求時的主機名:Host,之所以改變,有兩個原因:
- 我們使用了nginx反向代理,當監聽到api.leyou.com的時候,會自動將請求轉發至127.0.0.1:10010,即Zuul。
- 而後請求到達我們的網關Zuul,Zuul就會根據路徑匹配,我們的請求是/api/auth,根據規則被轉發到了 127.0.0.1:8087 ,即我們的授權中心。
我們首先去更改nginx配置,讓它不要修改我們的host:proxy_set_header Host $host;
把nginx進行reload:
nginx -s reload
這樣就解決了nginx這裏的問題。但是Zuul還會有一次轉發,所以要去修改網關的配置(leyou-gateway工程):
重啓後,我們再次測試。
最後計算得到的domain:
完美!
2.5.4.再次測試
我們再次登錄,發現依然沒有cookie!!
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(im
怎麼回事呢?
我們通過RestClient訪問下看看:
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Ch8AF6Rp-1573625758643)(assets/1527520407803.png)]
發現,響應頭中根本沒有set-cookie
了。
這是怎麼回事??
2.5.5.Zuul的敏感頭過濾
Zuul內部有默認的過濾器,會對請求和響應頭信息進行重組,過濾掉敏感的頭信息:
會發現,這裏會通過一個屬性爲SensitiveHeaders
的屬性,來獲取敏感頭列表,然後添加到IgnoredHeaders
中,這些頭信息就會被忽略。
而這個SensitiveHeaders
的默認值就包含了set-cookie
:
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-pBF3OYB1-1573625758644)(assets/1533733081367.png)]
解決方案有兩種:
全局設置:
zuul.sensitive-headers=
指定路由設置:
zuul.routes.<routeName>.sensitive-headers=
zuul.routes.<routeName>.custom-sensitive-headers=true
思路都是把敏感頭設置爲null
2.5.6.最後的測試
再次重啓後測試:
3.首頁判斷登錄狀態
雖然cookie已經成功寫入,但是我們首頁的頂部,登錄狀態依然沒能判斷出用戶信息:
這裏需要向後臺發起請求,根據cookie獲取當前用戶的信息。
我們先看頁面實現
3.1.頁面JS代碼
頁面的頂部已經被我們封裝爲一個獨立的Vue組件,在/js/pages/shortcut.js
中
打開js,發現裏面已經定義好了Vue組件,並且在created函數中,查詢用戶信息:
查看網絡控制檯,發現發起了請求:
因爲token在cookie中,因此本次請求肯定會攜帶token信息在頭中。
3.2.後臺實現校驗用戶接口
我們在leyou-auth-service
中定義用戶的校驗接口,通過cookie獲取token,然後校驗通過返回用戶信息。
- 請求方式:GET
- 請求路徑:/verify
- 請求參數:無,不過我們需要從cookie中獲取token信息
- 返回結果:UserInfo,校驗成功返回用戶信息;校驗失敗,則返回401
代碼:
/**
* 驗證用戶信息
* @param token
* @return
*/
@GetMapping("verify")
public ResponseEntity<UserInfo> verifyUser(@CookieValue("LY_TOKEN")String token){
try {
// 從token中解析token信息
UserInfo userInfo = JwtUtils.getInfoFromToken(token, this.properties.getPublicKey());
// 解析成功返回用戶信息
return ResponseEntity.ok(userInfo);
} catch (Exception e) {
e.printStackTrace();
}
// 出現異常則,響應500
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
3.3.測試
頁面效果:
3.4.刷新token
每當用戶在頁面進行新的操作,都應該刷新token的過期時間,否則30分鐘後用戶的登錄信息就無效了。而刷新其實就是重新生成一份token,然後寫入cookie即可。
那麼問題來了:我們怎麼知道用戶有操作呢?
事實上,每當用戶來查詢其個人信息,就證明他正在瀏覽網頁,此時刷新cookie是比較合適的時機。因此我們可以對剛剛的校驗用戶登錄狀態的接口進行改進,加入刷新token的邏輯。
/**
* 驗證用戶信息
* @param token
* @return
*/
@GetMapping("verify")
public ResponseEntity<UserInfo> verifyUser(@CookieValue("LY_TOKEN")String token, HttpServletRequest request, HttpServletResponse response){
try {
// 從token中解析token信息
UserInfo userInfo = JwtUtils.getInfoFromToken(token, this.properties.getPublicKey());
// 解析成功要重新刷新token
token = JwtUtils.generateToken(userInfo, this.properties.getPrivateKey(), this.properties.getExpire());
// 更新cookie中的token
CookieUtils.setCookie(request, response, this.properties.getCookieName(), token, this.properties.getCookieMaxAge());
// 解析成功返回用戶信息
return ResponseEntity.ok(userInfo);
} catch (Exception e) {
e.printStackTrace();
}
// 出現異常則,響應500
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
4.網關的登錄攔截器
接下來,我們在Zuul編寫攔截器,對用戶的token進行校驗,如果發現未登錄,則進行攔截。
4.1.引入jwt相關配置
既然是登錄攔截,一定是前置攔截器,我們在leyou-gateway
中定義。
首先在pom.xml中,引入所需要的依賴:
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
然後編寫application.yml屬性文件,添加如下內容:
leyou:
jwt:
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公鑰地址
cookieName: LY_TOKEN # cookie的名稱
編寫屬性類,讀取公鑰:
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {
private String pubKeyPath;// 公鑰
private PublicKey publicKey; // 公鑰
private String cookieName;
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
@PostConstruct
public void init(){
try {
// 獲取公鑰和私鑰
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
logger.error("初始化公鑰失敗!", e);
throw new RuntimeException();
}
}
public String getPubKeyPath() {
return pubKeyPath;
}
public void setPubKeyPath(String pubKeyPath) {
this.pubKeyPath = pubKeyPath;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
public String getCookieName() {
return cookieName;
}
public void setCookieName(String cookieName) {
this.cookieName = cookieName;
}
}
4.2.編寫過濾器邏輯
基本邏輯:
- 獲取cookie中的token
- 通過JWT對token進行校驗
- 通過:則放行;不通過:則重定向到登錄頁
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-C7t0GrjQ-1573625758660)(assets/1527557510032.png)]
@Component
@EnableConfigurationProperties(JwtProperties.class)
public class LoginFilter extends ZuulFilter {
@Autowired
private JwtProperties properties;
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 5;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
// 獲取上下文
RequestContext context = RequestContext.getCurrentContext();
// 獲取request
HttpServletRequest request = context.getRequest();
// 獲取token
String token = CookieUtils.getCookieValue(request, this.properties.getCookieName());
// 校驗
try {
// 校驗通過什麼都不做,即放行
JwtUtils.getInfoFromToken(token, this.properties.getPublicKey());
} catch (Exception e) {
// 校驗出現異常,返回403
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.FORBIDDEN.value());
}
return null;
}
}
重啓,刷新頁面,發現請求校驗的接口也被攔截了:
證明我們的攔截器生效了,但是,似乎有什麼不對的。這個路徑似乎不應該被攔截啊!
4.3.白名單
要注意,並不是所有的路徑我們都需要攔截,例如:
- 登錄校驗接口:
/auth/**
- 註冊接口:
/user/register
- 數據校驗接口:
/user/check/**
- 發送驗證碼接口:
/user/code
- 搜索接口:
/search/**
另外,跟後臺管理相關的接口,因爲我們沒有做登錄和權限,因此暫時都放行,但是生產環境中要做登錄校驗:
- 後臺商品服務:
/item/**
所以,我們需要在攔截時,配置一個白名單,如果在名單內,則不進行攔截。
在application.yaml
中添加規則:
leyou:
filter:
allowPaths:
- /api/auth
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item
然後讀取這些屬性:
內容:
@ConfigurationProperties(prefix = "leyou.filter")
public class FilterProperties {
private List<String> allowPaths;
public List<String> getAllowPaths() {
return allowPaths;
}
public void setAllowPaths(List<String> allowPaths) {
this.allowPaths = allowPaths;
}
}
在過濾器中的shouldFilter
方法中添加判斷邏輯:
代碼:
@Component
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class LoginFilter extends ZuulFilter {
@Autowired
private JwtProperties jwtProp;
@Autowired
private FilterProperties filterProp;
private static final Logger logger = LoggerFactory.getLogger(LoginFilter.class);
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 5;
}
@Override
public boolean shouldFilter() {
// 獲取上下文
RequestContext ctx = RequestContext.getCurrentContext();
// 獲取request
HttpServletRequest req = ctx.getRequest();
// 獲取路徑
String requestURI = req.getRequestURI();
// 判斷白名單
return !isAllowPath(requestURI);
}
private boolean isAllowPath(String requestURI) {
// 定義一個標記
boolean flag = false;
// 遍歷允許訪問的路徑
for (String path : this.filterProp.getAllowPaths()) {
// 然後判斷是否是符合
if(requestURI.startsWith(path)){
flag = true;
break;
}
}
return flag;
}
@Override
public Object run() throws ZuulException {
// 獲取上下文
RequestContext ctx = RequestContext.getCurrentContext();
// 獲取request
HttpServletRequest request = ctx.getRequest();
// 獲取token
String token = CookieUtils.getCookieValue(request, jwtProp.getCookieName());
// 校驗
try {
// 校驗通過什麼都不做,即放行
JwtUtils.getInfoFromToken(token, jwtProp.getPublicKey());
} catch (Exception e) {
// 校驗出現異常,返回403
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(403);
logger.error("非法訪問,未登錄,地址:{}", request.getRemoteHost(), e );
}
return null;
}
}
再次測試: