樂優商城(三十)——授權中心
目錄
一、無狀態登入原理
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:頭部,通常頭部有兩部分資訊:
對頭部進行base64加密(可解密),得到第一部分資料
-
宣告型別,這裡是JWT
-
加密演算法,自定義
-
-
Payload:載荷,就是有效資料,一般包含下面資訊:
這部分也會採用base64加密,得到第二部分資料
-
使用者身份資訊(注意,這裡因為採用base64加密,可解密,因此不要存放敏感資訊)
-
註冊宣告:如token的簽發時間,過期時間,簽發人等
-
-
Signature:簽名,是整個資料的認證資訊。一般根據前兩步的資料,再加上服務的的金鑰(secret)(不要洩漏,最好週期性更換),通過加密演算法生成。用於驗證整個資料完整和可靠性
生成的資料格式:
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
-
基本原理:加密過程中不需要使用金鑰,輸入明文後由系統直接經過加密演算法處理成密文,這種加密後的資料是無法被解密的,無法根據密文推算出明文。
-
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,獲取使用者資訊,無需訪問授權中心
二、授權中心
授權中心的主要職責:
-
使用者鑑權:
-
接收使用者的登入請求,通過使用者中心的介面進行校驗,通過後生成JWT
-
使用私鑰生成JWT並返回
-
-
服務鑑權:微服務間的呼叫不經過Zuul,會有風險,需要鑑權中心進行認證
-
原理與使用者鑑權類似,但邏輯稍微複雜一些(此處不做實現)
-
因為生成jwt,解析jwt這樣的行為以後在其它微服務中也會用到,因此抽取成工具。把鑑權中心進行聚合,一個工具module,一個提供服務的module。
2.1 建立授權中心
2.1.1 建立父module
將打包方式改為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.authentication</groupId>
<artifactId>leyou-authentication</artifactId>
<packaging>pom</packaging>
</project>
2.1.2 通用module
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-authentication</artifactId>
<groupId>com.leyou.authentication</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.authentication</groupId>
<artifactId>leyou-authentication-service</artifactId>
<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.authentication</groupId>
<artifactId>leyou-authentication-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
啟動器
package com.leyou.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* @Author: 98050
* @Time: 2018-10-23 20:11
* @Feature: 授權服務啟動器
*/
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LyAuthApplication {
public static void main(String[] args) {
SpringApplication.run(LyAuthApplication.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:
instance-id: ${spring.application.name}:${server.port}
prefer-ip-address: true #當你獲取host時,返回的不是主機名,而是ip
ip-address: 127.0.0.1
lease-expiration-duration-in-seconds: 10 #10秒不傳送九過期
lease-renewal-interval-in-seconds: 5 #每隔5秒發一次心跳
結構:
在leyou-gateway工程的application.yml中,修改路由:
2.2 JWT工具類
在leyou-authentication-common中匯入工具類:
需要在leyou-auth-common
中引入JWT依賴:
<?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-authentication</artifactId>
<groupId>com.leyou.authentication</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.authentication</groupId>
<artifactId>leyou-authentication-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
2.3 測試工具類
在leyou-authentication-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());
}
}
測試生成公鑰和私鑰,執行程式碼(注意將testGetRsa方法註釋掉):
檢視目標目錄:
公鑰和私鑰已經生成了!
測試生成token,把@Before的註釋去掉:
測試解析token:
正常解析:
2.4 編寫登入授權介面
接下來,需要在leyou-auth-servcice
編寫一個介面,對外提供登入授權服務。基本流程如下:
-
客戶端攜帶使用者名稱和密碼請求登入
-
授權中心呼叫客戶中心介面,根據使用者名稱和密碼查詢使用者資訊
-
如果使用者名稱密碼正確,能獲取使用者,否則為空,則登入失敗
-
如果校驗成功,則生成JWT並返回
2.4.1 生成公鑰和私鑰
在授權中心生成真正的公鑰和私鑰。所以必須有一個生成公鑰和私鑰的secret,這個可以配置到application.yml
中:
然後編寫屬性類,載入這些資料:
package com.leyou.auth.properties;
import com.leyou.auth.utils.RsaUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import javax.annotation.PostConstruct;
import java.io.File;
import java.security.PrivateKey;
import java.security.PublicKey;
/**
* @Author: 98050
* @Time: 2018-10-23 22:20
* @Feature: jwt配置引數
*/
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {
/**
* 金鑰
*/
private String secret;
/**
* 公鑰地址
*/
private String pubKeyPath;
/**
* 私鑰地址
*/
private String priKeyPath;
/**
* token過期時間
*/
private int expire;
/**
* 公鑰
*/
private PublicKey publicKey;
/**
* 私鑰
*/
private PrivateKey privateKey;
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public String getPubKeyPath() {
return pubKeyPath;
}
public void setPubKeyPath(String pubKeyPath) {
this.pubKeyPath = pubKeyPath;
}
public String getPriKeyPath() {
return priKeyPath;
}
public void setPriKeyPath(String priKeyPath) {
this.priKeyPath = priKeyPath;
}
public int getExpire() {
return expire;
}
public void setExpire(int expire) {
this.expire = expire;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
public PrivateKey getPrivateKey() {
return privateKey;
}
public void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
/**
* @PostConstruct :在構造方法執行之後執行該方法
*/
@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();
}
}
}
2.4.2 Controller
編寫授權介面,接收使用者名稱和密碼,校驗成功後,寫入cookie中。
-
請求方式:post
-
請求路徑:/accredit
-
請求引數:username和password
-
返回結果:無
程式碼:
注意配置屬性類:@EnableConfigurationProperties(JwtProperties.class)
package com.leyou.auth.controller;
import com.leyou.auth.properties.JwtProperties;
import com.leyou.auth.service.AuthService;
import com.leyou.utils.CookieUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @Author: 98050
* @Time: 2018-10-23 22:43
* @Feature: 登入授權
*/
@Controller
@EnableConfigurationProperties(JwtProperties.class)
public class AuthController {
@Autowired
private AuthService authService;
@Autowired
private JwtProperties properties;
@PostMapping("accredit")
public ResponseEntity<Void> authentication(
@RequestParam("username") String username,
@RequestParam("password") String password,
HttpServletRequest request,
HttpServletResponse response
){
//1.登入校驗
String token = this.authService.authentication(username,password);
if (StringUtils.isBlank(token)){
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
}
//2.將token寫入cookie,並指定httpOnly為true,防止通過js獲取和修改
CookieUtils.setCookie(request,response,properties.getCookieName(),token,properties.getCookieMaxAge(),true);
return ResponseEntity.ok().build();
}
}
2.4.3 CookieUtils
作用,將生成好的token放入到cookie中,返回到客戶端。
程式碼:略
2.4.4 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:
package com.leyou.auth.client;
import com.leyou.user.api.UserApi;
import org.springframework.cloud.openfeign.FeignClient;
/**
* @Author: 98050
* @Time: 2018-10-23 23:48
* @Feature: 使用者feignclient
*/
@FeignClient(value = "user-service")
public interface UserClient extends UserApi {
}
在leyou-user-interface中新增api介面:
package com.leyou.user.api;
import com.leyou.user.pojo.User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @Author: 98050
* @Time: 2018-10-23 23:50
* @Feature: 使用者服務介面
*/
public interface UserApi {
/**
* 使用者驗證
* @param username
* @param password
* @return
*/
@GetMapping("query")
User queryUser(@RequestParam("username")String username, @RequestParam("password")String password);
}
2.4.5 Service
介面
package com.leyou.auth.service;
/**
* @Author: 98050
* @Time: 2018-10-23 22:46
* @Feature:
*/
public interface AuthService {
/**
* 使用者授權
* @param username
* @param password
* @return
*/
String authentication(String username, String password);
}
實現
package com.leyou.auth.service.impl;
import com.leyou.auth.client.UserClient;
import com.leyou.auth.entity.UserInfo;
import com.leyou.auth.properties.JwtProperties;
import com.leyou.auth.service.AuthService;
import com.leyou.auth.utils.JwtUtils;
import com.leyou.user.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* @Author: 98050
* @Time: 2018-10-23 22:47
* @Feature:
*/
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private UserClient userClient;
@Autowired
private JwtProperties properties;
/**
* 使用者授權
* @param username
* @param password
* @return
*/
@Override
public String authentication(String username, String password) {
try{
//1.呼叫微服務查詢使用者資訊
User user = this.userClient.queryUser(username,password);
System.out.println(user);
//2.查詢結果為空,則直接返回null
if (user == null){
return null;
}
//3.查詢結果不為空,則生成token
String token = JwtUtils.generateToken(new UserInfo(user.getId(), user.getUsername()),
properties.getPrivateKey(), properties.getExpire());
System.out.println(token);
return token;
}catch (Exception e){
e.printStackTrace();
return null;
}
}
}
2.4.6 專案結構
2.4.7 測試
token已經生成,返回到客戶端。
2.5 登入頁面
請求路徑不對,因為認證介面是:
/api/auth/accredit
修改login.html:
然後登入,可以跳轉到首頁。
2.6 解決cookie寫入問題
檢視首頁cookie:
2.6.1 問題分析
之前測試時,看到了響應頭中,有Set-Cookie屬性,為什麼在這裡卻什麼都沒有?
因為涉及到跨域問題。跨域請求cookie生效的條件:
-
服務的響應頭中需要攜帶Access-Control-Allow-Credentials並且為true。
-
響應頭中的Access-Control-Allow-Origin一定不能為*,必須是指定的域名
-
瀏覽器發起ajax需要指定withCredentials 為true
服務端cors配置:
沒有問題。
客戶端ajax配置:common.js中
也沒有問題。
那說明,問題一定出在響應的set-cookie頭中。仔細檢視剛才的響應頭:
cookie的 domain
屬性似乎不太對
cookie也是有域
的限制,一個網頁,只能操作當前域名下的cookie,但是現在看到的地址是0.0.1(為什麼是0.0.1,因為前端的地址是www.leyou.com,通過nginx反向代理到127.0.0.1,通過計算得到domain為0.0.1),而頁面是www.leyou.com,域名不匹配,cookie設定肯定失敗了!
2.6.2 跟蹤CookieUtils
再次登入,然後進行Debug
CookieUtils內部有一個獲取domain的方法:
它獲取domain是通過伺服器的host來計算的,然而我們的地址竟然是:127.0.0.1:8087,因此後續的運算,最終得到的domain就變成了:
問題找到了:
請求時的serverName是:api.leyou.com,現在卻被變成了127.0.0.1:,因此計算domain是錯誤的,從而導致cookie設定失敗!
2.6.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重啟。
然後再去解決Zuul的問題,因為Zuul還會有一次轉發,所以要去修改閘道器的配置(leyou-gateway):
重啟專案,再次測試:
最後計算得到domain:
2.6.4 再次測試
還是沒有cookie!
2.6.5 Zuul的敏感頭過濾
Zuul內部有預設的過濾器,會對請求和響應頭資訊進行重組,過濾掉敏感的頭資訊:
會發現,這裡會通過一個屬性為SensitiveHeaders
的屬性,來獲取敏感頭列表,然後新增到IgnoredHeaders
中,這些頭資訊就會被忽略。
而這個SensitiveHeaders
的預設值就包含了set-cookie
:
解決方案有兩種:
全域性設定:
-
zuul.sensitive-headers=
指定路由設定:
-
zuul.routes.<routeName>.sensitive-headers=
-
zuul.routes.<routeName>.custom-sensitive-headers=true
思路都是把敏感頭設定為null
2.6.6 最後的測試
再次重啟,登入: