JWT認證原理,並整合SpringBoot
JWT認證原理,並整合SpringBoot
1、JWT是什麼?
JWT是Json web token (JWT), 是為了在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準((RFC 7519).該token被設計為緊湊且安全的,特別適用於分散式站點的單點登入(SSO)場景。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其它業務邏輯所必須的宣告資訊,該token也可直接被用於認證,也可被加密。
2、JWT的結構
JWT由三部分組成,結構是:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
中間由.
連線起來
-
header(頭部):
{ "alg": "HS256", #簽名使用的演算法,例如HMAC SHA256或RSA "typ": "JWT" #令牌的型別,即"JWT"
然後在利用base64加密:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
-
payload(有效負載):
{ "uid": "1", "username": "admin", "role": "admin" }
通常是跟使用者有關的實體,比如使用者名稱,使用者編號,使用者角色。注意:不要在payload中攜帶敏感資訊,比如使用者密碼。
然後在利用base64加密:
eyJ1aWQiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9
-
signature(簽名):
簽名信息跟三個部分有關:header(base64),payload(base64),secret(金鑰)
signature就是將header(base64)和payload(base64)使用
.
連線後的字串,然後利用header中宣告的加密演算法加鹽(金鑰)secret進行組合加密,最後就成了signature。// java String encodeHeader = new BASE64Encoder().encode(header); String encodePayload = new BASE64Encoder().encode(payload); String encodeHeaderAndPayload = encodeHeader + "." + encodePayload; String signature = HMACSHA256(encodeHeaderAndPayload , "secret");
需要注意的是,secret金鑰是儲存在伺服器的,是用來簽發jwt和驗證jwt的,同時jwt的簽發也在服務端。
簽名的目的:最後一部分簽名,實際上是防止header和payload被人篡改,雖然,這三樣東西在http傳輸中是暴露的,所有人都知道,但是簽名的驗證以及簽發就只能在服務端實現,即使你修改了header或者payload,但是生成的第三部分的所需要的secret金鑰別人是不知道的,所以其他人是無法篡改或者偽造的。
3、利用spring進行編碼和解碼
-
引入jwt依賴
<dependency><!--java-jwt--> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency>
-
測試生成token
/* * 生成header.payload.signature格式的jwt * */ @Test void encode(){ Calendar instance = Calendar.getInstance();// 獲取當前時間 instance.add(Calendar.SECOND,600);// 在當前時間上增加600秒 /* * withHeader宣告header * withClaim宣告payload(可以宣告多個) * sign宣告signature(利用演算法Algorithm.HMAC256(金鑰)) * */ String token = JWT.create() //.withHeader() //預設是{"typ":"JWT","alg":"HS256"} .withClaim("userId", 11) .withClaim("userName", "admin") //.withExpiresAt(instance.getTime()) //token過期時間(可選) .sign(Algorithm.HMAC256("!213213%^&")); System.out.println("生成的token->"+token); }
利用JWT的例項方法create()然後再新增header、payload、signature引數。就會生成一串JWT:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6ImFkbWluIiwidXNlcklkIjoxMX0.4km92AcjGRYvtwJeT8B2fhD8Qjfqq0SPvyH6m3X87cE
-
讀取token中的payload
/* * 解jwt * */ @Test void decode(){ /* * 1、結合演算法生成JWT驗證物件 * 2、利用JWT驗證物件驗證token的簽名是否正確 * 3、再從驗證通過後的decodedJWT物件中獲取引數 * */ JWTVerifier verifier = JWT.require(Algorithm.HMAC256("!213213%^&")).build(); DecodedJWT decodedJWT = verifier.verify("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6ImFkbWluIiwidXNlcklkIjoxMX0.4km92AcjGRYvtwJeT8B2fhD8Qjfqq0SPvyH6m3X87cE"); System.out.println(decodedJWT.getHeader()); // header的base64編碼 System.out.println(decodedJWT.getPayload()); // payload的base64編碼 System.out.println(decodedJWT.getSignature()); // signature的base64編碼 System.out.println(decodedJWT.getExpiresAt()); // 獲得jwt的失效時間 System.out.println(decodedJWT.getClaim("userId").asInt()); // payload中的userId的值 System.out.println(decodedJWT.getClaim("userName").asString()); // payload中的userName的值 }
注意必須先結合演算法驗證token中的簽名,然後才能獲取token中的引數。
4、利用java-jwt驗證token時的常見異常
SignatureVerificationException 簽名不一致
TokenExpiredException 令牌時效過期
AlgorithmMismatchException 演算法不匹配
InvalidClaimException payload失效
5、SpringBoot-web整合JWT
因為我們需要從資料庫中查詢是否存在該使用者,然後再給予使用者授權資訊,所以需要持久化的資料層。
-
封裝好JWTUtils
負責生成token和驗證token就可以了
JWTUtils.java:
public class JWTUtils { /* * secret金鑰 * */ private static final String SECRET = "!d$!3213#@[email protected]^G"; /** * 生成JWT物件 * @param payloadMap 有效負載集合 * @return token JWT令牌 */ public static String createToken(Map<String,String> payloadMap){ //1、建立JWT的builder構造器 JWTCreator.Builder jwtBuilder = JWT.create(); //2、遍歷payloadMap然後利用withClaim新增到JWT中 payloadMap.forEach((key,value)->{ //System.out.println(key+"-->"+value); jwtBuilder.withClaim(key,value); }); Calendar instance = Calendar.getInstance(); instance.add(Calendar.DATE,7); // 7天token有效期 String token = jwtBuilder .withExpiresAt(instance.getTime()) //3、新增JWT失效時間 .sign(Algorithm.HMAC256(SECRET)); //4、利用演算法結合SECRET給JWT簽名 return token; } /** * 獲得驗證簽名後的JWT物件: * @param token JWT令牌 * @return decodedJWT 解密後的JWT物件 */ public static DecodedJWT getTokenInfo(String token){ //1、結合演算法驗證JWT的簽名 JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build(); //2、解密JWT物件 DecodedJWT decodedJWT = verifier.verify(token); return decodedJWT; }
-
引入相關依賴
<dependency><!--java-jwt--> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency> <dependency><!--springboot-web--> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency><!--mybatis--> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.4</version> </dependency> <dependency><!--lombok--> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <dependency><!--mysql--> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.38</version> </dependency> <dependency><!--springboot-test--> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
-
實體層pojo
User.java:
@Data @NoArgsConstructor @AllArgsConstructor @Component public class User { /** * 使用者編號 */ private Integer uid; /** * 使用者名稱 */ private String username; /** * 使用者密碼 */ private String password; }
-
mapper對資料庫中資料進行增刪改查
這是對user表進行增刪改查,根據使用者名稱和密碼進行查詢(使用者登入驗證用),查詢所有使用者是登入成功後能夠進行的業務
UserMapper.java:
@Mapper @Repository public interface UserMapper { /** * 根據使用者名稱查詢使用者(用於登入) * @param username 使用者名稱 * @param password 密碼 * @return 查詢到的使用者 */ User selectUserByUsernameAndPassword(@Param("username")String username, @Param("password")String password); /** * 查詢所有使用者 * @return 使用者集合 */ List<User> selectAllUser(); }
UserMapper.xml:
<mapper namespace="cn.wqk.demo.mapper.UserMapper"> <select id="selectUserByUsernameAndPassword" resultType="cn.wqk.demo.pojo.User" parameterType="string"> SELECT * FROM user WHERE username=#{username} AND password=#{password} </select> <select id="selectAllUser" resultType="cn.wqk.demo.pojo.User"> SELECT * FROM user </select> </mapper>
-
service進一步封裝查詢出來的資料
登入業務
LoginService.java:
@Service public interface LoginService { /** * 檢查登入是否成功 * @param username 使用者名稱 * @param password 使用者密碼 * @return 查詢成功的使用者的完整資訊 */ User checkLogin(String username,String password) throws RuntimeException; }
LoginServiceImpl.java:
如果使用者名稱和密碼查詢成功,則直接返回查詢到的使用者物件,否則丟擲異常。
@Service public class LoginServiceImpl implements LoginService { @Autowired UserMapper userMapper; @Override public User checkLogin(String username, String password) throws RuntimeException { User userDB = userMapper.selectUserByUsernameAndPassword(username,password); if (userDB!=null){ // 使用者存在,返回查詢到的使用者資訊 return userDB; } throw new RuntimeException("認證失敗,請重新登入!"); //使用者不存在,丟擲異常 } }
UserService.java:
@Service public interface UserService { /** * 查詢所有使用者 * @return 使用者集合 */ List<User> allUser(); }
UserServiceImpl.java:
@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public List<User> allUser() { return userMapper.selectAllUser(); } }
-
controller實現介面進行登入和訪問
登入的controller:
@PostMapping("/login") public Map<String,Object> login(String username,String password){ System.out.println(username); System.out.println(password); // 初始化返回給前端的攜帶狀態碼的map Map<String, Object> returnMap = new HashMap<>(); try { // 認證成功 User userDB = loginService.checkLogin(username, password); Map<String, String> payloadMap = new HashMap<>(); payloadMap.put("uid",userDB.getUid().toString()); payloadMap.put("username",userDB.getUsername()); String token = JWTUtils.createToken(payloadMap); // 生成token returnMap.put("state",true); returnMap.put("msg","認證成功"); returnMap.put("token",token); } catch (RuntimeException e) { // 認證失敗 returnMap.put("state",false); returnMap.put("msg",e.getMessage()); } return returnMap; }
邏輯就是獲取到前端傳來的username和password,然後用於LoginService進行驗證,如果驗證通過則再將User物件的非敏感資訊塞進token理並且生成token,然後返回給前端。
檢視所有使用者業務的controller:
@PostMapping("/allUser") public Map<String,Object> allUser(String token){ System.out.println(token); HashMap<String, Object> returnMap = new HashMap<>(); returnMap.put("state",false); try { //驗證token DecodedJWT decodedJWT = JWTUtils.getTokenInfo(token); returnMap.put("state",true); returnMap.put("msg","認證成功"); List<User> userList = userService.allUser(); returnMap.put("data",userList); } catch (SignatureVerificationException e){ //簽名不一致 returnMap.put("msg","簽名不一致"); } catch (TokenExpiredException e){ //token過期 returnMap.put("msg","token過期"); } catch (AlgorithmMismatchException e) { // 演算法不匹配 returnMap.put("msg","簽名演算法不匹配"); } catch (InvalidClaimException e) { // payload失效 returnMap.put("msg","payload已失效"); } return returnMap; }
邏輯就是從前端裡獲取傳來的token,然後第一步就是驗證token,如果驗證成功則將查詢到的所有使用者塞進返回的Map物件裡,如果驗證不成功,則返回響應的異常。
所以SpringBoot-web整合JWT的完整思路就是:登入生成token,業務驗證token
先從前端獲取使用者名稱和密碼進行登入,如果登入成功則生成token並且塞進使用者的request裡面,這樣使用者後面所有的請求都必須攜帶這個token。使用者登入成功想要進行業務的話,就需要先驗證token是否有效並且合法,如果合法就能進行相應的業務,否則返回異常。
6、配置Web攔截器來攔截器並且結合JWT來驗證
上述的方法已經能夠進行發放token,並且驗證token是否合法了,但是有一個問題就是假如每有一個請求我都需要驗證的話我就需要寫很多重複的程式碼,所以我就可以交給SpringWeb的攔截器來做。
JWTInterceptor.java:
public class JWTInterceptor implements HandlerInterceptor {
/**
* 攔截器驗證token,驗證成功放行,否則不放行並且在response裡塞入狀態和錯誤資訊
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token"); // 從request請求頭中獲取token
HashMap<String, Object> returnMap = new HashMap<>(); //初始化返回給前端的map
try {
JWTUtils.getTokenInfo(token); //token驗證
return true; //token驗證通過過濾器放行
} catch (SignatureVerificationException e) { //簽名不一致
returnMap.put("msg","簽名不一致");
} catch (TokenExpiredException e) { //token已過期
returnMap.put("msg","token已過期");
} catch (AlgorithmMismatchException e) { //演算法不匹配
returnMap.put("msg","演算法不匹配");
} catch (InvalidClaimException e) { // payload已失效
returnMap.put("msg","payload已失效");
}
returnMap.put("status",false);
String json = new ObjectMapper().writeValueAsString(returnMap); //利用jackson將map轉為json
response.setContentType("application/json;charset=utf-8"); //設定response響應格式
response.getWriter().println(json); //將json塞進response裡
return false;
}
}
從request請求中獲取鍵為token的header,然後進行驗證,如果驗證通過直接放行,否則返回響應的狀態和錯誤資訊。需要注意的是interceptor的返回值是true放行,false不放行,所以我們需要在不放行的response返回資訊裡面塞進相應的提示資訊,所以就需要先將map集合轉為json物件,然後寫入到response裡記得設定response的格式。
寫好interceptor攔截器後就需要註冊攔截器:
InterceptorConfig.java:
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JWTInterceptor()) // 註冊JWT攔截器
.addPathPatterns("/user/**") // 要攔截的請求
.excludePathPatterns("/login/**"); // 不攔截的請求
}
}
7、關於JWT需要注意的
由於JWT生成的token的特性,token是存在客戶端的,驗證和發放是在服務端的,所以,並且在服務端的上驗證token的環節只是利用演算法和金鑰來驗證token而已。所以即使伺服器重新啟動,依舊不影響token的驗證,仍然能驗證通過。所以,建議,如果在修改了伺服器的相關配置後,建議修改金鑰,這樣就可以使得客戶端重新生成一次token,否則很有可能使用者可以拿之前的token進行現在的業務。
由於配置了攔截器,如果需要在業務中獲取token中的payload的話就直接從request的header中獲取就可以了。