寫在前面
在前一篇文章當中,我們介紹了springBoot整合spring security單體應用版,在這篇文章當中,我將介紹springBoot整合spring secury+JWT實現單點登錄與權限管理。
本文涉及的權限管理模型是基於資源的動態權限管理。數據庫設計的表有 user 、role、user_role、permission、role_permission。
單點登錄當中,關於訪問者信息的存儲有多種解決方案。如將其以key-value的形式存儲於redis數據庫中,訪問者令牌中存放key。校驗用戶身份時,憑借訪問者令牌中的key去redis中找value,沒找到則返回“令牌已過期”,讓訪問者去(重新)認證。本文中的demo,是將訪問者信息加密后存於token中返回給訪問者,訪問者攜帶令牌去訪問服務時,服務提供者直接解密校驗token即可。兩種實現各有優缺點。大家也可以嘗試着將本文中的demo的訪問者信息存儲改造成存在redis中的方式。文末提供完整的代碼及sql腳本下載地址。
在進入正式步驟之前,我們需要了解以下知識點。
單點登錄SSO
單點登錄也稱分布式認證,指的是在有多個系統的項目中,用戶經過一次認證,即可訪問該項目下彼此相互信任的系統。
單點登錄流程
給大家畫了個流程圖

關於JWT
jwt,全稱JSON Web Token,是一款出色的分布式身份校驗方案。
jwt由三個部分組成
- 頭部:主要設置一些規范信息,簽名部分的編碼格式就在頭部中聲明。
- 有效載荷:token中存放有效信息的部分,比如用戶名,用戶角色,過期時間等,但不適合放諸如密碼等敏感數據,會造成泄露。
- 簽名:將頭部與載荷分別采用base64編碼后,用“.”相連,再加入鹽,最后使用頭部聲明的編碼類型進行編碼,就得到了簽名。
jwt生成的Token安全性分析
想要使得token不被偽造,就要確保簽名不被篡改。然而,其簽名的頭部和有效載荷使用base64編碼,這與明文無異。因此,我們只能在鹽上做手腳了。我們對鹽進行非對稱加密后,在將token發放給用戶。
RSA非對稱加密
-
基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱秘保存,公鑰可以下發給信任客戶端 。
-
公鑰加密:只有私鑰才能解密
-
私鑰加密:私鑰或者公鑰都能解密
-
-
優缺點:
-
優點:安全、難以破解
-
缺點:耗時,但是為了安全,這是可以接受的
-
SpringSecurity+JWT+RSA分布式認證思路分析
通過之前的學習,我們知道了spring security主要是基於過濾器鏈來做認證的,因此,如何打造我們的單點登錄,突破口就在於spring security中的認證過濾器。
用戶認證
在分布式項目當中,現在大多數都是前后端分離架構設計的,因此,我們需要能夠接收POST請求的認證參數,而不是傳統的表單提交。因此,我們需要修改修
改UsernamePasswordAuthenticationFilter過濾器中attemptAuthentication方法,讓其能夠接收請求體。
關於spring security的認證流程分析,大家可以參考我上一篇文章《Spring Security認證流程分析--練氣后期》。
另外,默認情況下,successfulAuthentication 方法在通過認證后,直接將認證信息放到服務器的session當中就ok了。而我們分布式應用當中,前后端分離,禁用了session。因此,我們需要在認證通過后生成token(載荷內具有驗證用戶身份必要的信息)返回給用戶。
身份校驗
默認情況下,BasicAuthenticationFilter過濾器中doFilterInternal方法校驗用戶是否登錄,就是看session中是否有用戶信息。在分布式應用當中,我們要修改為,驗證用戶攜帶的token是否合法,並解析出用戶信息,交給SpringSecurity,以便於后續的授權功能可以正常使用。
實現步驟
(默認大家一已經創建好了數據庫)
第一步:創建一個springBoot的project
這個父工程主要做依賴的版本管理。
其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">
<modelVersion>4.0.0</modelVersion>
<modules>
<module>common</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<packaging>pom</packaging>
<groupId>pers.lbf</groupId>
<artifactId>springboot-springSecurity-jwt-rsa</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<java.version>1.8</java.version>
<jwt.version>0.10.7</jwt.version>
<jackson.version>2.11.2</jackson.version>
<springboot.version>2.3.3.RELEASE</springboot.version>
<mybatis.version>2.1.3</mybatis.version>
<mysql.version>8.0.12</mysql.version>
<joda.version>2.10.5</joda.version>
<springSecurity.version>5.3.4.RELEASE</springSecurity.version>
<common.version>1.0.0-SNAPSHOT</common.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>pers.lbf</groupId>
<artifactId>common</artifactId>
<version>${common.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${springboot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${springboot.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!--jwt所需jar包-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 處理日期-->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>${joda.version}</version>
</dependency>
<!--處理json工具包-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!--日志包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
<version>${springboot.version}</version>
</dependency>
<!--測試包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${springboot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>${springSecurity.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
第二步:創建三個子模塊
其中,common模塊作為公共模塊存在,提供基礎服務,包括token的生成、rsa加密密鑰的生成與使用、Json序列化與反序列化。
authentication-service模塊提供單點登錄服務(用戶認證及授權)。
product-service模塊模擬一個子系統。它主要負責提供接口調用和校驗用戶身份。
創建common模塊模塊
修改pom.xml,添加jwt、json等依賴
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>springboot-springSecurity-jwt-rsa</artifactId>
<groupId>pers.lbf</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>common</artifactId>
<dependencies>
<!--jwt所需jar包-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
<!--處理json工具包-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!--日志包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!--測試包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
創建一個JSON工具類
**json工具類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/2 22:28
*/
public class JsonUtils {
public static final ObjectMapper MAPPER = new ObjectMapper();
private static final Logger logger = LoggerFactory.getLogger(JsonUtils.class);
private JsonUtils() {
}
public static String toString(Object obj) {
if (obj == null) {
return null;
}
if (obj.getClass() == String.class) {
return (String) obj;
}
try {
return MAPPER.writeValueAsString(obj);
} catch (JsonProcessingException e) {
logger.error("json序列化出錯:" + obj, e);
return null;
}
}
public static <T> T toBean(String json, Class<T> tClass) {
try {
return MAPPER.readValue(json, tClass);
} catch (IOException e) {
logger.error("json解析出錯:" + json, e);
return null;
}
}
public static <E> List<E> toList(String json, Class<E> eClass) {
try {
return MAPPER.readValue(json, MAPPER.getTypeFactory().constructCollectionType(List.class, eClass));
} catch (IOException e) {
logger.error("json解析出錯:" + json, e);
return null;
}
}
public static <K, V> Map<K, V> toMap(String json, Class<K> kClass, Class<V> vClass) {
try {
return MAPPER.readValue(json, MAPPER.getTypeFactory().constructMapType(Map.class, kClass, vClass));
} catch (IOException e) {
logger.error("json解析出錯:" + json, e);
return null;
}
}
public static <T> T nativeRead(String json, TypeReference<T> type) {
try {
return MAPPER.readValue(json, type);
} catch (IOException e) {
logger.error("json解析出錯:" + json, e);
return null;
}
}
}
創建RSA加密工具類,並生成公鑰和密鑰文件
RsaUtils.java
/**RSA非對稱加密工具類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/2 22:27
*/
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**從文件中讀取公鑰
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:10:15
* @param filename 公鑰保存路徑,相對於classpath
* @return java.security.PublicKey 公鑰對象
* @throws Exception
* @version 1.0
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**從文件中讀取密鑰
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:12:01
* @param filename 私鑰保存路徑,相對於classpath
* @return java.security.PrivateKey 私鑰對象
* @throws Exception
* @version 1.0
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:12:59
* @param bytes 公鑰的字節形式
* @return java.security.PublicKey 公鑰對象
* @throws Exception
* @version 1.0
*/
private static PublicKey getPublicKey(byte[] bytes) throws Exception {
bytes = Base64.getDecoder().decode(bytes);
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**獲取密鑰
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:14:02
* @param bytes 私鑰的字節形式
* @return java.security.PrivateKey
* @throws Exception
* @version 1.0
*/
private static PrivateKey getPrivateKey(byte[] bytes) throws InvalidKeySpecException, NoSuchAlgorithmException {
bytes = Base64.getDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根據密文,生存rsa公鑰和私鑰,並寫入指定文件
*@author 賴柄灃 bingfengdev@aliyun.com
*@date 2020-09-04 13:14:02
* @param publicKeyFilename 公鑰文件路徑
* @param privateKeyFilename 私鑰文件路徑
* @param secret 生成密鑰的密文
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret, int keySize) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 獲取公鑰並寫出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
writeFile(publicKeyFilename, publicKeyBytes);
// 獲取私鑰並寫出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
writeFile(privateKeyFilename, privateKeyBytes);
}
/**讀文件
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:15:37
* @param fileName
* @return byte[]
* @throws
* @version 1.0
*/
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
/**寫文件
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:16:01
* @param destPath
* @param bytes
* @return void
* @throws
* @version 1.0
*/
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
/**構造器私有化
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-04 13:16:29
* @param
* @return
* @throws
* @version 1.0
*/
private RsaUtils() {
}
}
生成私鑰和公鑰兩個文件
/**
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 10:28
*/
public class RsaTest {
private String publicFile = "D:\\Desktop\\rsa_key.pub";
private String privateFile = "D:\\Desktop\\rsa_key";
/**生成公鑰和私鑰
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 10:32:16
* @throws Exception
* @version 1.0
*/
@Test
public void generateKey() throws Exception{
RsaUtils.generateKey(publicFile,privateFile,"Java開發實踐",2048);
}
}
私鑰文件一定要保護好!!!
私鑰文件一定要保護好!!!
私鑰文件一定要保護好!!!
(重要的事情說三遍!!!)
##### 創建token有效載荷實體類和JWT工具類
/**為了方便后期獲取token中的用戶信息,
* 將token中載荷部分單獨封裝成一個對象
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/2 22:24
*/
public class Payload<T> implements Serializable {
/**
* token id
*/
private String id;
/**
* 用戶信息(用戶名、角色...)
*/
private T userInfo;
/**
* 令牌過期時間
*/
private Date expiration;
getter。。。
setter。。。
}
JwtUtils
/**token工具類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/2 22:28
*/
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
/**
* 私鑰加密token
*
* @param userInfo 載荷中的數據
* @param privateKey 私鑰
* @param expire 過期時間,單位分鍾
* @return JWT
*/
public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusMinutes(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 私鑰加密token
*
* @param userInfo 載荷中的數據
* @param privateKey 私鑰
* @param expire 過期時間,單位秒
* @return JWT
*/
public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusSeconds(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 公鑰解析token
*
* @param token 用戶請求中的token
* @param publicKey 公鑰
* @return Jws<Claims>
*/
private static Jws<Claims> parserToken(String token, PublicKey publicKey) throws ExpiredJwtException {
return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
}
private static String createJTI() {
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
}
/**
* 獲取token中的用戶信息
*
* @param token 用戶請求中的令牌
* @param publicKey 公鑰
* @return 用戶信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) throws ExpiredJwtException {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setUserInfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(), userType));
claims.setExpiration(body.getExpiration());
return claims;
}
/**
* 獲取token中的載荷信息
*
* @param token 用戶請求中的令牌
* @param publicKey 公鑰
* @return 用戶信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setExpiration(body.getExpiration());
return claims;
}
private JwtUtils() {
}
}
寫完common模塊后,將其打包安裝,后面的兩個服務都需要引用。
創建認證服務模塊authentication-service
認證服務模塊的關鍵點在於自定義用戶認證過濾器和用戶校驗過濾器,並將其加載到spring security的過濾器鏈中,替代掉默認的。
##### 修改pom.xml文件,添加相關依賴
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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>pers.lbf</groupId>
<artifactId>springboot-springSecurity-jwt-rsa</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>authentication-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>authentication-service</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>pers.lbf</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
這個模塊添加的依賴主要是springBoot整合spring security的相關依賴以及數據庫相關的依賴,當然還有我們的common模塊。
修改application.yml文件
這一步主要是設置數據庫連接的信息以及公鑰、私鑰的位置信息
server:
port: 8081
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/security_authority?useSSL=false&serverTimezone=GMT
username: root
password: root1997
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
map-underscore-to-camel-case: true
logging:
level:
pers.lbf: debug
lbf:
key:
publicKeyPath: 你的公鑰路徑
privateKeyPath: 你的私鑰路徑
配置解析公鑰和私鑰
**解析公鑰和私鑰的配置類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 10:42
*/
@ConfigurationProperties(prefix = "lbf.key")
@ConstructorBinding
public class AuthServerRsaKeyProperties {
private String publicKeyPath;
private String privateKeyPath;
private PublicKey publicKey;
private PrivateKey privateKey;
/**加載文件當中的公鑰、私鑰
* 被@PostConstruct修飾的方法會在服務器加載Servlet的時候運行,
* 並且只會被服務器執行一次。PostConstruct在構造函數之后執行,
* init()方法之前執行。
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 12:07:35
* @throws Exception e
* @version 1.0
*/
@PostConstruct
public void loadKey() throws Exception {
publicKey = RsaUtils.getPublicKey(publicKeyPath);
privateKey = RsaUtils.getPrivateKey(privateKeyPath);
}
public String getPublicKeyPath() {
return publicKeyPath;
}
public void setPublicKeyPath(String publicKeyPath) {
this.publicKeyPath = publicKeyPath;
}
public String getPrivateKeyPath() {
return privateKeyPath;
}
public void setPrivateKeyPath(String privateKeyPath) {
this.privateKeyPath = privateKeyPath;
}
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;
}
}
修改啟動類,添加token加密解析的配置和mapper掃描
/**
* @author Ferryman
*/
@SpringBootApplication
@MapperScan(value = "pers.lbf.ssjr.authenticationservice.dao")
@EnableConfigurationProperties(AuthServerRsaKeyProperties.class)
public class AuthenticationServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthenticationServiceApplication.class, args);
}
}
創建用戶登錄對象UserLoginVO
我們將用戶登錄的請求參數封裝到一個實體類當中,而不使用與數據庫表對應的UserTO。
/**用戶登錄請求參數對象
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 16:16
*/
public class UserLoginVo implements Serializable {
private String username;
private String password;
getter。。。
settter。。。
}
創建用戶憑證對象UserAuthVO
這個對象主要用於存儲訪問者認證成功后,其在token中的信息。這里我們是不存儲密碼等敏感數據的。
/**用戶憑證對象
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 16:20
*/
public class UserAuthVO implements Serializable {
private String username;
private List<SimpleGrantedAuthority> authorities;
getter。。。
setter。。。
}
創建自定義認證過濾器
/**自定義認證過濾器
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 12:11
*/
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
/**
* 認證管理器
*/
private AuthenticationManager authenticationManager;
private AuthServerRsaKeyProperties prop;
/**構造注入
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 12:17:54
* @param authenticationManager spring security的認證管理器
* @param prop 公鑰 私鑰 配置類
* @version 1.0
*/
public TokenLoginFilter(AuthenticationManager authenticationManager, AuthServerRsaKeyProperties prop) {
this.authenticationManager = authenticationManager;
this.prop = prop;
}
/**接收並解析用戶憑證,並返回json數據
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 12:19:29
* @param request req
* @param response resp
* @return Authentication
* @version 1.0
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){
//判斷請求是否為POST,禁用GET請求提交數據
if (!"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException(
"只支持POST請求方式");
}
//將json數據轉換為java bean對象
try {
UserLoginVo user = new ObjectMapper().readValue(request.getInputStream(), UserLoginVo.class);
if (user.getUsername()==null){
user.setUsername("");
}
if (user.getPassword() == null) {
user.setPassword("");
}
user.getUsername().trim();
//將用戶信息交給spring security做認證操作
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
user.getUsername(),
user.getPassword()));
}catch (Exception e) {
throw new RuntimeException(e);
}
}
/**這個方法會在驗證成功時被調用
*用戶登錄成功后,生成token,並且返回json數據給前端
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 13:00:23
* @param request
* @param response
* @param chain
* @param authResult
* @version 1.0
*/
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult) {
//獲取當前登錄對象
UserAuthVO user = new UserAuthVO();
user.setUsername(authResult.getName());
user.setAuthorities((List<SimpleGrantedAuthority>) authResult.getAuthorities());
//使用jwt創建一個token,私鑰加密
String token = JwtUtils.generateTokenExpireInMinutes(user,prop.getPrivateKey(),15);
//返回token
response.addHeader("Authorization","Bearer"+token);
//登錄成功返回json數據提示
try {
//生成消息
Map<String, Object> map = new HashMap<>();
map.put("code",HttpServletResponse.SC_OK);
map.put("msg","登錄成功");
//響應數據
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(map));
writer.flush();
writer.close();
}catch (Exception e) {
throw new RuntimeException(e);
}
}
}
到了這一步,你或許會開始覺得難以理解,這需要你稍微了解spring security的認證流程。可以閱讀我之前的文章《Spring Security認證流程分析--練氣后期》。
創建自定義校驗過濾器
/**自定義身份驗證器
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 15:02
*/
public class TokenVerifyFilter extends BasicAuthenticationFilter {
private AuthServerRsaKeyProperties prop;
public TokenVerifyFilter(AuthenticationManager authenticationManager, AuthServerRsaKeyProperties prop) {
super(authenticationManager);
this.prop = prop;
}
/**過濾請求
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 15:07:27
* @param request
* @param response
* @param chain
* @version 1.0
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws ServletException, IOException, AuthenticationException,ExpiredJwtException {
//判斷請求體的頭中是否包含Authorization
String authorization = request.getHeader("Authorization");
//Authorization中是否包含Bearer,不包含直接返回
if (authorization==null||!authorization.startsWith("Bearer")){
chain.doFilter(request, response);
return;
}
UsernamePasswordAuthenticationToken token;
try {
//解析jwt生成的token,獲取權限
token = getAuthentication(authorization);
}catch (ExpiredJwtException e){
// e.printStackTrace();
chain.doFilter(request, response);
return;
}
//獲取后,將Authentication寫入SecurityContextHolder中供后序使用
SecurityContextHolder.getContext().setAuthentication(token);
chain.doFilter(request, response);
}
/**對jwt生成的token進行解析
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 15:21:04
* @param authorization auth
* @return org.springframework.security.authentication.UsernamePasswordAuthenticationToken
* @throws
* @version 1.0
*/
public UsernamePasswordAuthenticationToken getAuthentication(String authorization) throws ExpiredJwtException{
if (authorization == null) {
return null;
}
Payload<UserAuthVO> payload;
//從token中獲取有效載荷
payload = JwtUtils.getInfoFromToken(authorization.replace("Bearer", ""), prop.getPublicKey(), UserAuthVO.class);
//獲取當前訪問對象
UserAuthVO userInfo = payload.getUserInfo();
if (userInfo == null){
return null;
}
//將當前訪問對象及其權限封裝稱spring security可識別的token
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userInfo,null,userInfo.getAuthorities());
return token;
}
}
編寫spring security的配置類
這一步主要是是完成對spring security的配置。唯一和單體版應用集成spring'security不同的是,在這一步需要加入我們自定義的用戶認證和用戶校驗的過濾器,還有就是禁用session。
/**spring security配置類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 15:41
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userService;
@Autowired
private AuthServerRsaKeyProperties properties;
@Bean
public BCryptPasswordEncoder myPasswordEncoder(){
return new BCryptPasswordEncoder();
}
/**配置自定義過濾器
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 15:53:45
* @param http
* @version 1.0
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//禁用跨域保護,取代它的是jwt
http.csrf().disable();
//允許匿名訪問的方法
http.authorizeRequests().antMatchers("/login").anonymous();
//其他需要鑒權
//.anyRequest().authenticated();
//添加認證過濾器
http.addFilter(new TokenLoginFilter(authenticationManager(),properties));
//添加驗證過濾器
http.addFilter(new TokenVerifyFilter(authenticationManager(),properties));
//禁用session,前后端分離是無狀態的
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
/**配置密碼加密策略
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 15:50:46
* @param authenticationManagerBuilder
* @version 1.0
*/
@Override
protected void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(myPasswordEncoder());
}
@Override
public void configure(WebSecurity webSecurity) throws Exception{
//忽略靜態資源
webSecurity.ignoring().antMatchers("/assents/**","/login.html");
}
}
添加對GrantedAuthority類型的自定義反序列化工具
因為我們的權限信息是加密存儲於token中的,因此要對authorities進行序列化與反序列化,然后由於jackson並不支持對其進行反序列化,因此需要我們自己去做。
**
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 22:42
*/
public class CustomAuthorityDeserializer extends JsonDeserializer {
@Override
public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
ObjectMapper mapper = (ObjectMapper) jp.getCodec();
JsonNode jsonNode = mapper.readTree(jp);
List<GrantedAuthority> grantedAuthorities = new LinkedList<>();
Iterator<JsonNode> elements = jsonNode.elements();
while (elements.hasNext()) {
JsonNode next = elements.next();
JsonNode authority = next.get("authority");
grantedAuthorities.add(new SimpleGrantedAuthority(authority.asText()));
}
return grantedAuthorities;
}
}
在UserAuthVO上標記
/**用戶憑證對象
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 16:20
*/
public class UserAuthVO implements Serializable {
@JsonDeserialize(using = CustomAuthorityDeserializer.class)
public void setAuthorities(List<SimpleGrantedAuthority> authorities) {
this.authorities = authorities;
}
//省略了其他無關的代碼
}
實現UserDetailsService接口
實現loadUserByUsername方法,修改認證信息獲取方式為:從數據庫中獲取權限信息。
/**
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/8/28 22:16
*/
@Service("userService")
public class UserServiceImpl implements UserDetailsService {
@Autowired
private IUserDao userDao;
@Autowired
private IRoleDao roleDao;
@Autowired
private IPermissonDao permissonDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (username == null){
return null;
}
UserDO user = userDao.findByName(username);
List<RoleDO> roleList = roleDao.findByUserId(user.getId());
List<SimpleGrantedAuthority> list = new ArrayList<> ();
for (RoleDO roleDO : roleList) {
List<PermissionDO> permissionListItems = permissonDao.findByRoleId(roleDO.getId());
for (PermissionDO permissionDO : permissionListItems) {
list.add(new SimpleGrantedAuthority(permissionDO.getPermissionUrl()));
}
}
user.setAuthorityList(list);
return user;
}
}
提示:關於用戶、角色、權限的數據庫操作及其實體類到這里就省略了,不影響大家理解,當然,文末提供了完整的代碼下載地址。
自定義401和403異常處理
Spring Security 中的異常主要分為兩大類:一類是認證異常,另一類是授權相關的異常。並且,其拋出異常的地方是在過濾器鏈中,如果你使用@ControllerAdvice是沒有辦法處理的。
當然,像spring security這么優秀的框架,當然考慮到了這個問題。
spring security當中的HttpSecurity提供的exceptionHandling() 方法用來提供異常處理。該方法構造出 ExceptionHandlingConfigurer異常處理配置類。
然后該類呢有提供了兩個接口用於我們自定義異常處理:
- AuthenticationEntryPoint 該類用來統一處理 AuthenticationException異常(403異常)
- AccessDeniedHandler 該類用來統一處理 AccessDeniedException異常(401異常)
MyAuthenticationEntryPoint.java
/**401異常處理
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 22:08
*/
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
response.setStatus(200);
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
map.put("msg","令牌已過期請重新登錄");
ServletOutputStream out = response.getOutputStream();
String s = new ObjectMapper().writeValueAsString(map);
byte[] bytes = s.getBytes();
out.write(bytes);
}
}
MyAccessDeniedHandler.java
/**403異常處理
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 22:11
*/
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
response.setStatus(200);
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_FORBIDDEN);
map.put("msg","未授權訪問此資源,如有需要請聯系管理員授權");
ServletOutputStream out = response.getOutputStream();
String s = new ObjectMapper().writeValueAsString(map);
byte[] bytes = s.getBytes();
out.write(bytes);
}
}
將這兩個類添加到spring security的配置當中
/**spring security配置類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/3 15:41
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userService;
@Autowired
private AuthServerRsaKeyProperties properties;
@Bean
public BCryptPasswordEncoder myPasswordEncoder(){
return new BCryptPasswordEncoder();
}
/**配置自定義過濾器
* @author 賴柄灃 bingfengdev@aliyun.com
* @date 2020-09-03 15:53:45
* @param http
* @version 1.0
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//其他代碼。。。
//添加自定義異常處理
http.exceptionHandling().authenticationEntryPoint(new MyAuthenticationEntryPoint());
http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());
//其他代碼1
}
}
到這一步大家就可以運行啟動類先進行測試一下。在本文當中就先將product-service模塊也實現了再集中測試
創建子系統模塊product-service
修改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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>pers.lbf</groupId>
<artifactId>springboot-springSecurity-jwt-rsa</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>product-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>product-service</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>pers.lbf</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
修改application.yml配置文件
這里主要是配置數據庫信息和加入公鑰的地址信息
server:
port: 8082
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/security_authority?useSSL=false&serverTimezone=GMT
username: root
password: root1997
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
map-underscore-to-camel-case: true
logging:
level:
pers.lbf: debug
lbf:
key:
publicKeyPath: 你的公鑰地址
創建讀取公鑰的配置類
/**讀取公鑰配置類
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/9/4 10:05
*/
@ConfigurationProperties(prefix = "lbf.key")
@ConstructorBinding
public class ProductRsaKeyProperties {
private String publicKeyPath;
private PublicKey publicKey;
@PostConstruct
public void loadKey() throws Exception {
publicKey = RsaUtils.getPublicKey(publicKeyPath);
}
@Override
public String toString() {
return "ProductRsaKeyProperties{" +
"pubKeyPath='" + publicKeyPath + '\'' +
", publicKey=" + publicKey +
'}';
}
public String getPublicKeyPath() {
return publicKeyPath;
}
public void setPublicKeyPath(String publicKeyPath) {
this.publicKeyPath = publicKeyPath;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
}
修改啟動類
這一步和創建認證服務器時一樣,如要是加入公鑰配置和mapper掃描
/**
* @author Ferryman
*/
@SpringBootApplication
@MapperScan(basePackages = "pers.lbf.ssjr.productservice.dao")
@EnableConfigurationProperties(ProductRsaKeyProperties.class)
public class ProductServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ProductServiceApplication.class, args);
}
}
復制
這一步主要是將UserAuthVo、自定義校驗器、自定義異常處理器和自定義反序列化器從認證服務模塊復制過來。(之所以不放入到公共模塊common中是因為。不想直接在common模塊中引入springBoot整合spring security的依賴)
創建子模塊spring security配置類
這里也只需要在認證服務模塊的配置上修改即可,去掉自定義認證過濾器的內容。資源模塊只負責校驗,不做認證。
創建一個測試接口
/**
* @author 賴柄灃 bingfengdev@aliyun.com
* @version 1.0
* @date 2020/8/27 20:02
*/
@RestController
@RequestMapping("/product")
public class ProductController {
@GetMapping("/get")
@PreAuthorize("hasAuthority('product:get')")
public String get() {
return "產品信息接口調用成功!";
}
}
第三步:啟動項目,進行測試
登錄(認證)操作
登錄成功返回消息提示

並且可以在請求頭中看到token

登陸失敗提示"用戶名或密碼錯誤"

訪問資源
攜帶令牌訪問資源,且具備權限、令牌未過期

攜帶token訪問資源。但是沒有權限

未攜帶token訪問(未登錄、未經過認證)

攜帶過期令牌訪問資源

寫在最后
springBoot整合security實現權限管理與認證分布式版(前后端分離版)的的核心在於三個問題
-
禁用了session,用戶信息保存在哪?
-
如何實現對訪問者的認證,或者說是根據token去認證訪問者?
-
如何實現對訪問者的校驗,或者說是根據token去校驗訪問者身份?
基本上我們解決了上面三個問題之后,springBoot整合spring security實現前后端分離(分布式)場景下的權限管理與認證問題我們就可以說是基本解決了。
代碼以及sql腳本下載方式:微信搜索關注公眾號【Java開發實踐】,回復20200904即可得到下載鏈接。
