1. 程式人生 > 程式設計 >利用Springboot實現Jwt認證的示例程式碼

利用Springboot實現Jwt認證的示例程式碼

JSON Web Token是目前最流行的跨域認證解決方案,,適合前後端分離專案通過Restful API進行資料互動時進行身份認證

image-20200221234116337

關於Shiro整合JWT,可以看這裡:Springboot實現Shiro+JWT認證

概述

由於概念性內容網上多的是,所以就不詳細介紹了

具體可以看這裡:阮一峰大佬的部落格

我總結幾個重點:

JWT,全稱Json Web Token,是一種令牌認證的方式

長相:

image-20200221230910489

  • 頭部:放有簽名演算法和令牌型別(這個就是JWT)
  • 載荷:你在令牌上附帶的資訊:比如使用者的id,使用者的電話號碼,這樣以後驗證了令牌之後就可以直接從這裡獲取資訊而不用再查資料庫了
  • 簽名:用來加令牌的

安全性:由於載荷裡的內容都是用BASE64處理的,所以是沒有保密性的(因為BASE64是對稱的),但是由於簽名認證的原因,其他人很難偽造資料。不過這也意味著,你不能把敏感資訊比如密碼放入載荷中,畢竟這種可以被別人直接看到的,但是像使用者id這種就無所謂了

工作流程

登入階段

使用者首次登入,通過賬號密碼比對,判定是否登入成功,如果登入成功的話,就生成一個jwt字串,然後放入一些附帶資訊,返回給客戶端。

image-20200221234215011

這個jwt字串裡包含了有使用者的相關資訊,比如這個使用者是誰,他的id是多少,這個令牌的有效時間是多久等等。下次使用者登入的時候,必須把這個令牌也一起帶上。

認證階段

這裡需要和前端統一約定好,在發起請求的時候,會把上次的token放在請求頭裡的某個位置一起傳送過來,後端接受到請求之後,會解析jwt,驗證jwt是否合法,有沒有被偽造,是否過期,到這裡,驗證過程就完成了。

image-20200221234900958

不過伺服器同樣可以從驗證後的jwt裡獲取使用者的相關資訊,從而減少對資料庫的查詢。

比如我們有這樣一個業務:“通過使用者電話號碼查詢使用者餘額”

如果我們在jwt的載荷裡事先就放有電話號碼這個屬性,那麼我們就可以避免先去資料庫根據使用者id查詢使用者電話號碼,而直接拿到電話號碼,然後執行接下里的業務邏輯。

關於有效期

由於jwt是直接給使用者的,只要能驗證成功的jwt都可以被視作登入成功,所以,如果不給jwt設定一個過期時間的話,使用者只要存著這個jwt,就相當於永遠登入了,而這是不安全的,因為如果這個令牌洩露了,那麼伺服器是沒有任何辦法阻止該令牌的持有者訪問的(因為拿到這個令牌就等於隨便冒充你身份訪問了),所以往往jwt都會有一個有效期,通常存在於載荷部分,下面是一段生成jwt的java程式碼:

 return JWT.create().withAudience(userId)
  .withIssuedAt(new Date()) <---- 發行時間
  .withExpiresAt(expiresDate) <---- 有效期
  .withClaim("sessionId",sessionId)
  .withClaim("userName",userName)
  .withClaim("realName",realName)
  .sign(Algorithm.HMAC256(userId+"HelloLehr"));

在實際的開發中,令牌的有效期往往是越短越安全,因為令牌會頻繁變化,即使有某個令牌被別人盜用,也會很快失效。但是有效期短也會導致使用者體驗不好(總是需要重新登入),所以這時候就會出現另外一種令牌—refresh token重新整理令牌。重新整理令牌的有效期會很長,只要重新整理令牌沒有過期,就可以再申請另外一個jwt而無需登入(且這個過程是在使用者訪問某個介面時自動完成的,使用者不會感覺到令牌替換),對於重新整理令牌的具體實現這裡就不詳細講啦(其實因為我也沒深入研究過XD…)

對比Session

在傳統的session會話機制中,伺服器識別使用者是通過使用者首次訪問伺服器的時候,給使用者一個sessionId,然後把使用者對應的會話記錄放在伺服器這裡,以後每次通過sessionId來找到對應的會話記錄。這樣雖然所有的資料都存在伺服器上是安全的,但是對於分散式的應用來說,就需要考慮session共享的問題了,不然同一個使用者的sessionId的請求被自動分配到另外一個伺服器上就等於失效了

而Jwt不但可以用於登入認證,也把相應的資料返回給了使用者(就是載荷裡的內容),通過簽名來保證資料的真實性,該應用的各個伺服器上都有統一的驗證方法,只要能通過驗證,就說明你的令牌是可信的,我就可以從你的令牌上獲取你的資訊,知道你是誰了,從而減輕了伺服器的壓力,而且也對分散式應用更為友好。(畢竟就不用擔心伺服器session的分散式儲存問題了)

整合Springboot

匯入java-jwt包

匯入java-jwt包:

這個包裡實現了一系列jwt操作的api(包括上面講到的怎麼校驗,怎麼生成jwt等等)

如果你是Maven玩家:

pom.xml裡寫入

<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
 <groupId>com.auth0</groupId>
 <artifactId>java-jwt</artifactId>
 <version>3.8.3</version>
</dependency>

如果你是Gradle玩家:

build.gradle裡寫入

compile group: 'com.auth0',name: 'java-jwt',version: '3.8.3'

如果你是其他玩家:

maven中央倉庫地址點這裡

工具類的編寫

程式碼如下:

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;

import java.io.Serializable;
import java.util.Calendar;
import java.util.Date;

/**
 * @author Lehr
 * @create: 2020-02-04
 */
public class JwtUtils {

 /**
 簽發物件:這個使用者的id
 簽發時間:現在
 有效時間:30分鐘
 載荷內容:暫時設計為:這個人的名字,這個人的暱稱
 加密金鑰:這個人的id加上一串字串
 */
 public static String createToken(String userId,String realName,String userName) {

 Calendar nowTime = Calendar.getInstance();
 nowTime.add(Calendar.MINUTE,30);
 Date expiresDate = nowTime.getTime();

 return JWT.create().withAudience(userId) //簽發物件
  .withIssuedAt(new Date()) //發行時間
  .withExpiresAt(expiresDate) //有效時間
  .withClaim("userName",userName) //載荷,隨便寫幾個都可以
  .withClaim("realName",realName)
  .sign(Algorithm.HMAC256(userId+"HelloLehr")); //加密
 }

 /**
 * 檢驗合法性,其中secret引數就應該傳入的是使用者的id
 * @param token
 * @throws TokenUnavailable
 */
 public static void verifyToken(String token,String secret) throws TokenUnavailable {
 DecodedJWT jwt = null;
 try {
  JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret+"HelloLehr")).build();
  jwt = verifier.verify(token);
 } catch (Exception e) {
  //效驗失敗
  //這裡丟擲的異常是我自定義的一個異常,你也可以寫成別的
  throw new TokenUnavailable();
 }
 }

 /**
 * 獲取簽發物件
 */
 public static String getAudience(String token) throws TokenUnavailable {
 String audience = null;
 try {
  audience = JWT.decode(token).getAudience().get(0);
 } catch (JWTDecodeException j) {
  //這裡是token解析失敗
  throw new TokenUnavailable();
 }
 return audience;
 }


 /**
 * 通過載荷名字獲取載荷的值
 */
 public static Claim getClaimByName(String token,String name){
 return JWT.decode(token).getClaim(name);
 }
}

一點小說明:

關於jwt生成時的加密和驗證方法:

jwt的驗證其實就是驗證jwt最後那一部分(簽名部分)。這裡在指定簽名的加密方式的時候,還傳入了一個字串來加密,所以驗證的時候不但需要知道加密演算法,還需要獲得這個字串才能成功解密,提高了安全性。我這裡用的是id來,比較簡單,如果你想更安全一點,可以把使用者密碼作為這個加密字串,這樣就算是這段業務程式碼洩露了,也不會引發太大的安全問題(畢竟我的id是誰都知道的,這樣令牌就可以被偽造,但是如果換成密碼,只要資料庫沒事那就沒人知道)

關於獲得載荷的方法:

可能有人會覺得奇怪,為什麼不需要解密不需要verify就能夠獲取到載荷裡的內容呢?原因是,本來載荷就只是用Base64處理了,就沒有加密性,所以能直接獲取到它的值,但是至於可不可以相信這個值的真實性,就是要看能不能通過驗證了,因為最後的簽名部分是和前面頭部和載荷的內容有關聯的,所以一旦簽名驗證過了,那就說明前面的載荷是沒有被改過的。

註解類的編寫

在controller層上的每個方法上,可以使用這些註解,來決定訪問這個方法是否需要攜帶token,由於預設是全部檢查,所以對於某些特殊介面需要有免驗證註解

免驗證註解

@PassToken:跳過驗證,通常是入口方法上用這個,比如登入介面

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author Lehr
 * @create: 2020-02-03
 */
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
 boolean required() default true;
}

攔截器的編寫

配置類

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* @author lehr
*/
@Configuration
public class JwtInterceptorConfig implements WebMvcConfigurer {
 @Override
 public void addInterceptors(InterceptorRegistry registry) {

 //預設攔截所有路徑
 registry.addInterceptor(authenticationInterceptor())
  .addPathPatterns("/**");
 }
 @Bean
 public JwtAuthenticationInterceptor authenticationInterceptor() {
 return new JwtAuthenticationInterceptor();
 }
} 

攔截器

import com.auth0.jwt.interfaces.Claim;
import com.imlehr.internship.annotation.PassToken;
import com.imlehr.internship.dto.AccountDTO;
import com.imlehr.internship.exception.NeedToLogin;
import com.imlehr.internship.exception.UserNotExist;
import com.imlehr.internship.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.Map;

/**
 * @author Lehr
 * @create: 2020-02-03
 */
public class JwtAuthenticationInterceptor implements HandlerInterceptor {
 @Autowired
 AccountService accountService;

 @Override
 public boolean preHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object object) throws Exception {
 // 從請求頭中取出 token 這裡需要和前端約定好把jwt放到請求頭一個叫token的地方
 String token = httpServletRequest.getHeader("token");
 // 如果不是對映到方法直接通過
 if (!(object instanceof HandlerMethod)) {
  return true;
 }
 HandlerMethod handlerMethod = (HandlerMethod) object;
 Method method = handlerMethod.getMethod();
 //檢查是否有passtoken註釋,有則跳過認證
 if (method.isAnnotationPresent(PassToken.class)) {
  PassToken passToken = method.getAnnotation(PassToken.class);
  if (passToken.required()) {
  return true;
  }
 }
 //預設全部檢查
 else {
  System.out.println("被jwt攔截需要驗證");
  // 執行認證
  if (token == null) {
  //這裡其實是登入失效,沒token了 這個錯誤也是我自定義的,讀者需要自己修改
  throw new NeedToLogin();
  }
  
  // 獲取 token 中的 user Name
  String userId = JwtUtils.getAudience(token);

  //找找看是否有這個user 因為我們需要檢查使用者是否存在,讀者可以自行修改邏輯
  AccountDTO user = accountService.getByUserName(userId);

  if (user == null) {
  //這個錯誤也是我自定義的
  throw new UserNotExist();
  }

  // 驗證 token 
  JwtUtils.verifyToken(token,userId)
  
  //獲取載荷內容
 	String userName = JwtUtils.getClaimByName(token,"userName").asString();
 	String realName = JwtUtils.getClaimByName(token,"realName").asString();
 	
  //放入attribute以便後面呼叫
  request.setAttribute("userName",userName);
 	request.setAttribute("realName",realName);
  

  return true;

 }
 return true;
 }

 @Override
 public void postHandle(HttpServletRequest httpServletRequest,Object o,ModelAndView modelAndView) throws Exception {

 }

 @Override
 public void afterCompletion(HttpServletRequest httpServletRequest,Exception e) throws Exception {
 }
}

這段程式碼的執行邏輯大概是這樣的:

  • 目標方法是否有註解?如果有PassToken的話就不用執行後面的驗證直接放行,不然全部需要驗證
  • 開始驗證:有沒有token?沒有?那麼返回錯誤
  • 從token的audience中獲取簽發物件,檢視是否有這個使用者(有可能客戶端造假,有可能這個使用者的賬戶被凍結了),檢視使用者的邏輯就是呼叫Service方法直接比對即可
  • 檢驗Jwt的有效性,如果無效或者過期了就返回錯誤
  • Jwt有效性檢驗成功:把Jwt的載荷內容獲取到,可以在接下來的controller層中直接使用了(具體使用方法看後面的程式碼)

介面的編寫

這裡設計了兩個介面:登入和查詢名字,來模擬一個迷你業務,其中後者需要登入之後才能使用,大致流程如下:

image-20200222005538848

登入程式碼

/**
 * 使用者登入:獲取賬號密碼並登入,如果不對就報錯,對了就返回使用者的登入資訊
 * 同時生成jwt返回給使用者
 *
 * @return
 * @throws LoginFailed 這個LoginFailed也是我自定義的
 */
 @PassToken
 @GetMapping(value = "/login")
 public AccountVO login(String userName,String password) throws LoginFailed{
 
 try{
  service.login(userName,password);
 }
 catch (AuthenticationException e)
 {
  throw new LoginFailed();
 }

 //如果成功了,聚合需要返回的資訊
 AccountVO account = accountService.getAccountByUserName(userName);
 
 //給分配一個token 然後返回
 String jwtToken = JwtUtils.createToken(account);

 //我的處理方式是把token放到accountVO裡去了
 account.setToken(jwtToken);

 return account;

 }

業務程式碼

這裡列舉一個需要登入,用來測試使用者名稱字的介面(其中使用者的名字來源於jwt的載荷部分)

 @GetMapping(value = "/username")
 public String checkName(HttpServletRequest req) {
 //之前在攔截器裡設定好的名字現在可以取出來直接用了
 String name = (String) req.getAttribute("userName");
 return name;
 }

到此這篇關於利用Springboot實現Jwt認證的示例程式碼的文章就介紹到這了,更多相關Springboot Jwt認證內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!