系列導航
SpringSecurity系列
- SpringSecurity系列學習(一):初識SpringSecurity
- SpringSecurity系列學習(二):密碼驗證
- SpringSecurity系列學習(三):認證流程和源碼解析
- SpringSecurity系列學習(四):基於JWT的認證
- SpringSecurity系列學習(四-番外):多因子驗證和TOTP
- SpringSecurity系列學習(五):授權流程和源碼分析
- SpringSecurity系列學習(六):基於RBAC的授權
SpringSecurityOauth2系列
- SpringSecurityOauth2系列學習(一):初認Oauth2
- SpringSecurityOauth2系列學習(二):授權服務
- SpringSecurityOauth2系列學習(三):資源服務
- SpringSecurityOauth2系列學習(四):自定義登陸登出接口
- SpringSecurityOauth2系列學習(五):授權服務自定義異常處理
基於JWT的認證
憋嗦話,上號!
咳咳,在上號之前,再聊兩句。。。
分布式認證
分布式認證,即我們常說的單點登錄,簡稱SSO,指的是在多應用系統的項目中,用戶只需要登錄一次,就可以訪問所有互相信任的應用系統。
但是首先,我們要明確,在分布式項目中,每台服務器都有各自獨立的session,而這些session之間是無法直接共享資源的,所以,session通常不能被作為單點登錄的技術方案。
最合理的單點登錄方案流程如下圖所示:
總結一下,單點登錄的實現分兩大環節:
- 用戶認證:這一環節主要是客戶端向認證服務器發起認證請求,認證服務器給客戶端返回一個成功的令牌token,主要在認證服務器中完成,認證服務器只能有一個。
- 身份校驗:這一環節是客戶端攜帶token去訪問其他服務器時,在其他服務器中要對token的真偽進行檢驗,主要在資源服務器中完成,資源服務器可以有很多個。
JWT
從分布式認證流程中,我們不難發現,這中間起最關鍵作用的就是token,token的安全與否,直接關系到系統的健壯性,這里我們選擇使用JWT來實現token的生成和校驗。
JWT,全稱JSON Web Token,官網地址https://jwt.io 是一款出色的分布式身份校驗方案。可以生成token,也可以解析檢驗token。
JWT生成的token由三部分組成:
- 頭部(header):主要設置一些規范信息,簽名部分的編碼格式就在頭部中聲明(用的什么加密算法)。
- 載荷(payload):token中存放有效信息的部分,比如用戶名,用戶角色,過期時間等,但是不要放密碼,會泄露!
- 簽名(sign):將頭部與載荷分別采用base64編碼后,用“.”相連,再加入鹽,最后使用頭部聲明的編碼類型進行編碼,就得到了簽名。
其中JWT在荷載中已經聲明的字段:
lss
:簽發者exb
:過期時間sub
:主題aud
:目標受眾- ...
還可以使用 Jwts.claim(key,value)
添加字段
從JWT生成的token組成上來看,要想避免token被偽造,主要就得看簽名部分了,而簽名部分又有三部分組成,其中頭部和載荷的base64編碼,幾乎是透明的,毫無安全性可言,那么最終守護token安全的重擔就落在了加入的鹽上面了!
試想:如果生成token所用的鹽與解析token時加入的鹽是一樣的。豈不是類似於中國人民銀行把人民幣防偽技術公開了?大家可以用這個鹽來解析token,就能用來偽造token。
這時,我們就需要對鹽采用非對稱加密的方式進行加密,以達到生成token與校驗token方所用的鹽不一致的安全效果!
非對稱加密
基本原理:同時生成兩把密鑰:私鑰和公鑰,私鑰隱秘保存,公鑰可以下發給信任客戶端
- 私鑰加密,持有公鑰才可以解密
- 公鑰加密,持有私鑰才可解密
優點:安全,難以破解
缺點:算法比較耗時,為了安全,可以接受
縮寫:RSA。
對稱加密算法不能實現簽名,因此簽名只能非對稱算法。
實戰
具體的代碼可以看githubspring-security-demo
引入依賴
我們首先開始實現JWT的創建,新建一個項目,引入依賴
Springboot:2.4.3
SpringSecurity:5.4.5
Mybatis-plus:3.4.3
mysql-connector:8.0.25
<?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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cupricnitrate</groupId>
<artifactId>spring-security-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<jjwt.version>0.11.2</jjwt.version>
<mybatis.plus.version>3.4.3</mybatis.plus.version>
<mysql.version>8.0.25</mysql.version>
</properties>
<dependencies>
<!--SpringSecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--SpringBootWeb-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--參數驗證-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--ORM-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!--jwt相關依賴-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!--自定義配置屬性自動補全-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
創建JWT
上文說到,JWT中有一個荷載(payload)部分,其包含了有效信息,比如用戶名,用戶角色,過期時間等。這里定義一個荷載實體類
/**
* 荷載類
* 為了方便后期獲取token中的用戶信息,將token中載荷部分單獨封裝成一個對象
* @author 硝酸銅
* @date 2021/9/22
*/
@Data
public class Payload<T> {
private String id;
private T userInfo;
private Date expiration;
private Date issuedAt;
}
/**
* 荷載中的數據
* @author 硝酸銅
* @date 2021/9/22
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ClaimInfo {
/**
* 用戶名
*/
private String username;
/**
* 權限
*/
private List<ClaimAuthority> authorities;
@Data
public static class ClaimAuthority implements GrantedAuthority{
private String authority;
@Override
public String getAuthority() {
return this.authority;
}
}
}
這里的ClaimAuthority
類就是權限,其需要實現SpringSecurity的接口GrantedAuthority
,SpringSecurity就是通過getAuthority()
來讀取權限的
JWT的簽名部分,推薦使用非對稱加密的形式,非對稱加密的形式更為安全,當然在安全性要求沒有這么高的情況下,不使用非對稱加密也是可以的。
這里實現一下非對稱加密的工具類,便於生成JWT
package com.cupricnitrate.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* RSA工具類
*
* @author 硝酸銅
* @date 2021/9/22
*/
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* 從文件中讀取公鑰
*
* @param filename 公鑰保存路徑
* @return 公鑰對象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 從文件中讀取密鑰
*
* @param filename 私鑰保存路徑
* @return 私鑰對象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 獲取公鑰
*
* @param bytes 公鑰的字節形式
* @return
* @throws Exception
*/
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);
}
/**
* 獲取密鑰
*
* @param bytes 私鑰的字節形式
* @return
* @throws Exception
*/
private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException,
InvalidKeySpecException {
bytes = Base64.getDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根據密文,生成rsa公鑰和私鑰,並寫入指定文件
*
* @param publicKeyFilename 公鑰文件絕對路徑,比如:xxx/xxx/rsa_key.pub
* @param privateKeyFilename 私鑰文件絕對路徑,比如:xxx/xxx/rsa_key
* @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);
}
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
secret) throws Exception {
generateKey(publicKeyFilename,privateKeyFilename,secret,DEFAULT_KEY_SIZE);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
File dir = dest.getParentFile();
//判斷目錄是否存在,不在則新建
if(!dir.exists()){
dir.mkdirs();
}
//判斷文件是否存在,不在則新建
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
有了這個工具類,就可以獲取公鑰,私鑰了
接下來,實現一個生成JWT的工具類
package com.cupricnitrate.util;
import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.*;
/**
* 生成token以及校驗token相關方法
*
* @author 硝酸銅
* @date 2021/9/22
*/
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
// 用於HS512加密 簽名的key
public static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512);
/**
* 私鑰加密token
*
* @param claimInfo 載荷中的數據
* @param key key
* @param expire 過期時間,單位ms
* @return JWT
*/
public static String generateTokenExpire(Object claimInfo,
Key key,
long expire,
String id) {
long now = System.currentTimeMillis();
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, claimInfo)
.setId(id)
.setExpiration(new Date(now + expire))
.setIssuedAt(new Date(now))
//RS256加密
.signWith(key, SignatureAlgorithm.RS256)
//如果使用HS512加密則使用這個
//.signWith(key, SignatureAlgorithm.HS512).compact();
.compact();
}
/**
* 解析token
*
* @param token 用戶請求中的token
* @param key key
* @return Jws<Claims>
*/
public static Jws<Claims> parserToken(String token, Key key) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
}
public static String createJTI() {
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
}
/**
* 獲取token中的用戶信息
*
* @param token 用戶請求中的令牌
* @param key key
* @return 用戶信息
*/
public static <T> Payload<T> getInfoFromToken(String token, Key key, Class<T> userType) {
Jws<Claims> claimsJws = parserToken(token, key);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
ObjectMapper objectMapper = new ObjectMapper();
claims.setUserInfo(objectMapper.convertValue(body.get(JWT_PAYLOAD_USER_KEY),userType));
claims.setExpiration(body.getExpiration());
claims.setIssuedAt(body.getIssuedAt());
return claims;
}
/**
* 獲取token中的載荷信息
*
* @param token 用戶請求中的令牌
* @param key key
* @return 用戶信息
*/
public static <T> Payload<T> getInfoFromToken(String token, Key key) {
Jws<Claims> claimsJws = parserToken(token, key);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setExpiration(body.getExpiration());
claims.setIssuedAt(body.getIssuedAt());
return claims;
}
/**
* 驗證 token,忽略過期
*
* @param jwtToken token
* @param key key
* @return boolean
*/
public static boolean validateWithoutExpiration(String jwtToken, Key key) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
return true;
} catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
if (e instanceof ExpiredJwtException) {
return true;
}
}
return false;
}
/**
* 驗證token
*
* @param jwtToken token
* @param key key
* @return boolean
*/
public static boolean validateToken(String jwtToken, Key key) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwtToken);
return true;
} catch (ExpiredJwtException | SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
return false;
}
}
}
寫一個main
方法測試一下
public static void main(String[] args) throws Exception {
//生成訪問令牌公鑰和私鑰文件
String keyPublicFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key.pub";
String keyPrivateFilePath = "/Users/xiaoshengpeng/auth_key/key/rsa_key";
//RsaUtils.generateKey(keyPublicFilePath, keyPrivateFilePath, "CupricNitrate Key Token");
//生成刷新令牌公鑰和私鑰文件
String refreshPublicFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub";
String refreshPrivateFilePath = "/Users/xiaoshengpeng/auth_key/refresh/rsa_key";
//RsaUtils.generateKey(refreshPublicFilePath, refreshPrivateFilePath, "CupricNitrate Refresh Token");
//模擬加密生成token
PublicKey publicKey = RsaUtils.getPublicKey(keyPublicFilePath);
PrivateKey privateKey = RsaUtils.getPrivateKey(keyPrivateFilePath);
//權限設置
List<ClaimInfo.ClaimAuthority> authorities = new ArrayList<>();
ClaimInfo.ClaimAuthority authority = new ClaimInfo.ClaimAuthority();
authority.setAuthority("ROLE_USER");
authorities.add(authority);
//荷載數據
ClaimInfo claimInfo = ClaimInfo.builder()
.username("user")
.authorities(authorities)
.build();
//生成token
String token = JwtUtils.generateTokenExpire(claimInfo, privateKey, 24 * 60 * 60 * 1000, createJTI());
System.out.println("token: " + token);
//模擬解密從token中獲取用戶信息
ObjectMapper objectMapper = new ObjectMapper();
//序列化時忽略值為null的屬性
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
Payload<User> payload = JwtUtils.getInfoFromToken(token,
publicKey, User.class);
User user1 = payload.getUserInfo();
System.out.println("user: " + objectMapper.writeValueAsString(user1));
}
控制台輸出:
token: eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjp7InVzZXJuYW1lIjoidXNlciIsImF1dGhvcml0aWVzIjpbeyJhdXRob3JpdHkiOiJST0xFX1VTRVIifV19LCJqdGkiOiJPV1F6Tnpoa01HWXRNbVpoWWkwME9HUTRMV0ptTWpVdFl6TTVPRGczT1dVMk1XRTMiLCJleHAiOjE2MzI0NDk3NDgsImlhdCI6MTYzMjM2MzM0OH0.Isga_gn8JcskbFsIrzYWuzB-oLusSD7kUM6_gEr8TP5y7RrB0eS2l7YoNCox0xsdVBkf2ANn-zwqYvlqy3bDFCVgbNiNjUiZ3YiD0MJliR2J1Ci2sg0Tjt98EnxkzaS07rg9kfhrGhZ0vdb_EwMVmms1o6-a-gj1baOFsF3_ZmBL_rR4lDDPY5R86SUTJdVksZTBsnxv4bmqV15asnaJg8_f6mYuCH-dMeJ836G_EFgGu4XsqC_n5Fkm9fxkwzpzLdTlAXawgdiPeNhxwn_8bHcWhnP0m62L1pQj39mj15ghISAUs0EWlFP6DKNddCAQf3gDjfVPt5f-CSTv0JD04Q
user: {"username":"user","authorities":[{"authority":"ROLE_USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true}
使用這個token在jwt.io上解析:
這里可以看到JWT是可以通過被解析出來的,其非對稱加密只是將簽名進行加密,在驗簽的時候起到安全防護的作用。所以不要在JWT中存放敏感信息!
訪問令牌和刷新令牌
上文的main
方法中,我生成了兩套公私鑰,訪問令牌公私鑰和刷新令牌公私鑰,為什么需要兩個令牌呢?我使用一個令牌不就可以了嗎?當然可以,但是使用兩個令牌更為安全!
令牌一旦簽發出去之后,就默認已經暴露出去了,因為不論我們保存在客戶端還是服務器中,別人想要獲取這個令牌,是很容易的。
所以,對於公開的令牌,我們采用的策略就是減少它的生命周期,比如說5分鍾有效,這樣別人拿到令牌之后,這個令牌可能已經過期了,令牌泄漏了也不會造成太大的影響。
還有就是在服務端做一些異常檢查,因為一個安全系統,是不能單純的依賴一個單一的手段的,比如還需要在服務端中檢查高頻訪問的IP這些東西,做一些限制。綜合來判斷是否有人盜取了用戶的信息,防止惡意用戶的攻擊。
- 訪問令牌(直接開門的鑰匙):聲明周期要短,一般在幾分鍾到幾小時之間,防止令牌暴露之后,黑客為所欲為
- 刷新令牌(不能直接開門,是用來生成新的訪問令牌用的):生命周期會長,應該在幾周到不超過一年
- 上面兩種令牌使用不同的key簽發
- 當訪問令牌過期之后,使用刷新令牌去生成新的訪問令牌。由於生成新的訪問令牌需要同時驗證刷新令牌和過期的訪問令牌,所以就降低了令牌暴露的危險。
創建JWT過濾器
SpringSecurityOauth2有內建的JwtFilter
,並且支持得很好
但是SpringSecurity里面沒有單體應用的JwtFilter
,需要我們自己實現
- 認證成功就是把
Authentication
對象setAuthenticated(true)
,然后存到SecurityContext
中。 - 認證失敗就是清空
SecurityContext
然后交給下一個Filter處理
首先自定義一個屬性,便於token相關的配置
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* token配置屬性類
* @author 硝酸銅
* @date 2021/9/22
*/
@Data
@Component
@ConfigurationProperties(prefix = TokenPropertities.PREFIX)
public class TokenProperties {
public static final String PREFIX = "token";
/**
* Http報頭中令牌自定義標識,默認:Authorization
*/
private String header = "Authorization";
/**
* Http報頭中令牌自定義標識中的開頭,默認:Bearer
*/
private String prefix = "Bearer";
/**
* 訪問令牌相關屬性
*/
private AccessToken access;
/**
* 刷新令牌相關屬性
*/
private RefreshToken refresh;
@Data
public static class AccessToken{
/**
* 訪問令牌過期時間,單位ms,默認60s
*/
private Long expireTime = 60 * 1000L;
/**
* 訪問令牌私鑰文件訪問路徑,比如/user/auth_key/rsa_key
*/
private String privateKey;
/**
* 訪問令牌公鑰文件訪問路徑,比如/user/auth_key/rsa_key.pub
*/
private String publicKey;
}
@Data
public static class RefreshToken{
/**
* 刷新令牌過期時間,單位ms,默認30天
*/
private Long expireTime = 30 * 24 * 60 * 60 * 1000L;
/**
* 訪問令牌私鑰文件訪問路徑,比如/user/auth_key/rsa_key
*/
private String privateKey;
/**
* 訪問令牌公鑰文件訪問路徑,比如/user/auth_key/rsa_key.pub
*/
private String publicKey;
}
}
然后在application.yaml
中將其配置好
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
#mysql驅動8.x版本使用com.mysql.cj.jdbc.Driver
#5.x使用com.mysql.jdbc.Driver
driver-class-name: com.mysql.cj.jdbc.Driver
#數據庫地址
url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
#數據庫賬號
username: root
#數據庫密碼
password: root
#hikari連接池
hikari:
#2*cpu
maximum-pool-size: 16
#cpu
minimum-idle: 8
data-source-properties:
cachePrepStmts: true
prepStmtCacheSize: 250
prepStmtCacheSqlLimit: 2048
useServerPrepStmts: true
token:
access:
public-key: /Users/xiaoshengpeng/auth_key/key/rsa_key.pub
private-key: /Users/xiaoshengpeng/auth_key/key/rsa_key
refresh:
public-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key.pub
private-key: /Users/xiaoshengpeng/auth_key/refresh/rsa_key
接下來我們來實現簽發token的步驟。
一般我們我們將簽發token的步驟寫在登錄中
還記得我們怎么自定義認證邏輯的嗎?
創建一個Jwt認證過濾器,使其繼承UsernamePasswordAuthenticationFilter
import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.http.req.LoginReqDto;
import com.cupricnitrate.http.resp.LoginRespDto;
import com.cupricnitrate.model.Authority;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
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 javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Jwt認證過濾器
* @author 硝酸銅
* @date 2021/9/22
*/
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final TokenProperties tokenProperties;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager,TokenProperties tokenProperties) {
this.authenticationManager = authenticationManager;
this.tokenProperties = tokenProperties;
// 瀏覽器訪問 /authorize/login 會通過 JWTAuthenticationFilter
setFilterProcessesUrl("/authorize/login");
}
/**
* json格式:
*
* {
* "username": "user",
* "password": "12345678"
* }
*
* @param request 請求體
* @param response 返回體
* @return Authentication
* @throws AuthenticationException 認證異常
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
InputStream is = null;
LoginReqDto req = null;
try {
//從Body中讀取參數
is = request.getInputStream();
//使用jackson解析json
ObjectMapper objectMapper = new ObjectMapper();
req = objectMapper.readValue(is,LoginReqDto.class);
} catch (IOException e) {
e.printStackTrace();
throw new BadCredentialsException("json格式錯誤,沒有找到用戶名或密碼");
}
//認證,同父類,生成一個沒有被完全初始化的Authentication
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
this.setDetails(request, authRequest);
return authenticationManager.authenticate(authRequest);
}
/**
* 認證成功邏輯
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//令牌私鑰
PrivateKey accessPrivateKey = null;
PrivateKey refreshPrivateKey = null;
try {
accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
refreshPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getRefresh().getPrivateKey());
} catch (Exception e) {
e.printStackTrace();
}
//創建荷載信息
List<ClaimInfo.ClaimAuthority> authorities = authResult.getAuthorities().stream().map(a -> {
ClaimInfo.ClaimAuthority claimAuthority = new ClaimInfo.ClaimAuthority();
claimAuthority.setAuthority(a.getAuthority());
return claimAuthority;
}).collect(Collectors.toList());
ClaimInfo claim = ClaimInfo.builder().username(authResult.getName()).authorities(authorities).build();
//簽發token,使用私鑰進行簽發
LoginRespDto respDto = new LoginRespDto(
JwtUtils.generateTokenExpire(claim, accessPrivateKey,tokenProperties.getAccess().getExpireTime(), JwtUtils.createJTI()),
JwtUtils.generateTokenExpire(claim, refreshPrivateKey,tokenProperties.getRefresh().getExpireTime(),JwtUtils.createJTI()));
try {
//登錄成功時,返回json格式進行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_OK);
map.put("message", "登陸成功!");
map.put("token",respDto);
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
/**
* 登錄失敗邏輯
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
try {
//登錄成功時,返回json格式進行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_FORBIDDEN);
map.put("message", "登陸失敗!");
map.put("reason",failed.getMessage());
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
}
具體邏輯可以看注釋,很清晰了
完成了認證邏輯,還需要實現一個驗證token的過濾器,客戶端在登陸后,請求接口的時候,在Http Header中帶上Authorization:Bearer XXX
(xxx是JWT)即可通過認證
import com.cupricnitrate.config.property.TokenProperties;
import com.cupricnitrate.model.ClaimInfo;
import com.cupricnitrate.model.Payload;
import com.cupricnitrate.util.JwtUtils;
import com.cupricnitrate.util.RsaUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author 硝酸銅
* @date 2021/9/22
*/
public class JwtVerifyFilter extends BasicAuthenticationFilter {
private TokenProperties tokenProperties;
public JwtVerifyFilter(AuthenticationManager authenticationManager, TokenProperties tokenProperties) {
super(authenticationManager);
this.tokenProperties = tokenProperties;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
if (checkJwtToken(request)) {
try {
//獲取權限失敗,會拋出異常
UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
//獲取后,將Authentication寫入SecurityContextHolder中供后序使用
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (Exception e) {
responseJson(response);
e.printStackTrace();
}
} else {
//token不在請求頭中,則說明是匿名用戶訪問
List<GrantedAuthority> list = new ArrayList<>();
GrantedAuthority grantedAuthority = () -> "ROLE_ANONYMOUS";
list.add(grantedAuthority);
AnonymousAuthenticationToken authentication = new AnonymousAuthenticationToken("token-anonymousUser","anonymousUser", list);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
/**
* 檢查JWT Token 是否在HTTP 報頭中
*
* @param request HTTP請求
* @return boolean
*/
private boolean checkJwtToken(HttpServletRequest request) {
String header = request.getHeader(tokenProperties.getHeader());
return header != null && header.startsWith(tokenProperties.getPrefix());
}
/**
* 未登錄提示
*
* @param response
*/
private void responseJson(HttpServletResponse response) {
try {
//未登錄提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<String, Object>();
map.put("code", HttpServletResponse.SC_FORBIDDEN);
map.put("message", "未登錄或登錄過期,請進行登錄!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
}
/**
* 通過token,獲取用戶信息
*
* @param request
* @return
*/
@SneakyThrows
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
//讀取請求頭中的Authorization的值
String token = request.getHeader("Authorization");
if (token != null) {
//Authorization 中JWT傳參,默認格式 Authorization:Bearer XXX
token = token.replaceFirst(tokenProperties.getPrefix(), "");
//通過token解析出載荷信息,使用公鑰進行解析
Payload<ClaimInfo> payload = JwtUtils.getInfoFromToken(token, RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey()), ClaimInfo.class);
ClaimInfo claimInfo = payload.getUserInfo();
//不為null,返回一個完全初始化的Authentication
if (claimInfo != null) {
return new UsernamePasswordAuthenticationToken(claimInfo.getUsername(), null, claimInfo.getAuthorities());
}
return null;
}
return null;
}
}
最后,將這兩個安全過濾器設置到SpringSecurity的安全配置中即可
/**
* @author 硝酸銅
* @date 2021/9/22
*/
@EnableWebSecurity(debug = true)
@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private TokenProperties tokenProperties;
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//禁用生成默認的登陸頁面
.formLogin(AbstractHttpConfigurer::disable)
//關閉httpBasic,采用自定義過濾器
.httpBasic(AbstractHttpConfigurer::disable)
//前后端分離架構不需要csrf保護,這里關閉
.csrf(AbstractHttpConfigurer::disable)
//禁用生成默認的注銷頁面
.logout(AbstractHttpConfigurer::disable)
.authorizeRequests(req -> req
//允許訪問authorize url下的所有接口
.antMatchers("/authorize/**").permitAll()
.anyRequest().authenticated()
)
//添加我們自定義的過濾器,替代UsernamePasswordAuthenticationFilter
.addFilterAt(new JwtAuthenticationFilter(authenticationManager(),tokenProperties), UsernamePasswordAuthenticationFilter.class)
//添加token檢驗過濾器
.addFilter(new JwtVerifyFilter(authenticationManager(),tokenProperties))
//前后端分離是無狀態的,不用session了,直接禁用。
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
...
}
刷新令牌接口
登陸是獲取令牌的接口,那么訪問令牌過期了,需要使用刷新令牌去刷新令牌
我們接下來實現一下刷新令牌的接口
/**
* @author 硝酸銅
* @date 2021/9/22
*/
@RestController
@RequestMapping("/authorize")
public class AuthorizeController {
...
/**
* 刷新訪問令牌
* @param req 請求體
* @param authorization 訪問令牌
* @return LoginRespDto
*/
@PostMapping(value = "/refreshToken")
public LoginRespDto refreshToken(@Validated @RequestBody RefreshTokenReqDto req, @RequestHeader(name = "Authorization") String authorization){
return tokenService.refreshToken(authorization.replaceFirst("Bearer ", ""),req.getRefreshToken());
}
...
}
/**
* @author 硝酸銅
* @date 2021/9/22
*/
@Data
public class RefreshTokenReqDto implements Serializable {
private static final long serialVersionUID = 8410311036049755024L;
/**
* 刷新令牌
*/
@NotBlank
private String refreshToken;
}
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Optional;
/**
* @author 硝酸銅
* @date 2021/9/22
*/
@Service
public class TokenService {
@Resource
private TokenProperties tokenProperties;
/**
* 使用刷新token創建訪問token
* @param token 訪問token
* @param refreshToken 刷新token
* @return 訪問token
*/
public LoginRespDto refreshToken(String token, String refreshToken){
LoginRespDto resp = new LoginRespDto();
//獲取公鑰和私鑰
PublicKey accessPublicKey = null;
PrivateKey accessPrivateKey = null;
PublicKey refreshPublicKey = null;
PrivateKey refreshPrivateKey = null;
try {
//訪問令牌公鑰
accessPublicKey = RsaUtils.getPublicKey(tokenProperties.getAccess().getPublicKey());
//訪問令牌私鑰
accessPrivateKey = RsaUtils.getPrivateKey(tokenProperties.getAccess().getPrivateKey());
//刷新令牌公鑰
refreshPublicKey = RsaUtils.getPublicKey(tokenProperties.getRefresh().getPublicKey());
} catch (Exception e) {
e.printStackTrace();
}
//解析刷新令牌並生成新的訪問令牌
if(JwtUtils.validateWithoutExpiration(token,accessPublicKey) &&
JwtUtils.validateToken(refreshToken,refreshPublicKey)){
PrivateKey key = accessPrivateKey;
//生成新的訪問令牌
String accessToken = Optional.ofNullable(JwtUtils.parserToken(refreshToken, refreshPublicKey))
.map(claims ->
JwtUtils.generateTokenExpire(claims.getBody(),
key,
tokenProperties.getAccess().getExpireTime(),
JwtUtils.createJTI()))
.orElseThrow(() -> new AccessDeniedException("訪問被拒絕"));
resp.setAccessToken(accessToken);
}
return resp;
}
}
調用接口
我們來驗證一下,先進行登陸:
這個時候訪問接口是可以通過認證的
等待一定時間,訪問令牌過期
這個時候,調用刷新令牌接口,生成新的訪問令牌
使用新的訪問令牌,又能通過認證了
到此為止,關於認證的事情我們就做完了
接下來我們來學習一下授權的事情