Spring Security 實戰乾貨:使用 JWT 認證訪問介面

碼農小胖哥發表於2019-11-08

jwt.png

1. 前言

歡迎閱讀Spring Security 實戰乾貨系列。之前我講解了如何編寫一個自己的 Jwt 生成器以及如何在使用者認證通過後返回 Json Web Token 。今天我們來看看如何在請求中使用 Jwt 訪問鑑權。DEMO 獲取方法在文末。

2. 常用的 Http 認證方式

我們要在 Http 請求中使用 Jwt 我們就必須瞭解 常見的 Http 認證方式。

2.1 HTTP Basic Authentication

HTTP Basic Authentication 又叫基礎認證,它簡單地使用 Base64 演算法對使用者名稱、密碼進行加密,並將加密後的資訊放在請求頭 Header 中,本質上還是明文傳輸使用者名稱、密碼,並不安全,所以最好在 Https 環境下使用。其認證流程如下:

basic.png

客戶端發起 GET 請求 服務端響應返回 401 Unauthorizedwww-Authenticate 指定認證演算法,realm 指定安全域。然後客戶端一般會彈窗提示輸入使用者名稱稱和密碼,輸入使用者名稱密碼後放入 Header 再次請求,服務端認證成功後以 200 狀態碼響應客戶端。

2.2 HTTP Digest Authentication

為彌補 BASIC 認證存在的弱點就有了 HTTP Digest Authentication 。它又叫摘要認證。它使用隨機數加上 MD5 演算法來對使用者名稱、密碼進行摘要編碼,流程類似 Http Basic Authentication ,但是更加複雜一些:

步驟1:跟基礎認證一樣,只不過返回帶 WWW-Authenticate 首部欄位的響應。該欄位內包含質問響應方式認證所需要的臨時諮詢碼(隨機數,nonce)。 首部欄位 WWW-Authenticate 內必須包含 realmnonce 這兩個欄位的資訊。客戶端就是依靠向伺服器回送這兩個值進行認證的。nonce 是一種每次隨返回的 401 響應生成的任意隨機字串。該字串通常推薦由 Base64 編碼的十六進位制數的組成形式,但實際內容依賴伺服器的具體實現

步驟2:接收到 401 狀態碼的客戶端,返回的響應中包含 DIGEST 認證必須的首部欄位 Authorization 資訊。首部欄位 Authorization 內必須包含 username、realm、nonce、uriresponse 的欄位資訊,其中,realmnonce 就是之前從伺服器接收到的響應中的欄位。

步驟3:接收到包含首部欄位 Authorization 請求的伺服器,會確認認證資訊的正確性。認證通過後則會返回包含 Request-URI 資源的響應。

並且這時會在首部欄位 Authorization-Info 寫入一些認證成功的相關資訊。

2.3 SSL 客戶端認證

SSL 客戶端認證就是通常我們說的 HTTPS 。安全級別較高,但需要承擔 CA 證照費用。SSL 認證過程中涉及到一些重要的概念,數字證照機構的公鑰、證照的私鑰和公鑰、非對稱演算法(配合證照的私鑰和公鑰使用)、對稱金鑰、對稱演算法(配合對稱金鑰使用)。相對複雜一些這裡不過多講述。

2.4 Form 表單認證

Form 表單的認證方式並不是HTTP規範。所以實現方式也呈現多樣化,其實我們平常的掃碼登入,手機驗證碼登入都屬於表單登入的範疇。表單認證一般都會配合 CookieSession 的使用,現在很多 Web 站點都使用此認證方式。使用者在登入頁中填寫使用者名稱和密碼,服務端認證通過後會將 sessionId 返回給瀏覽器端,瀏覽器會儲存 sessionId 到瀏覽器的 Cookie 中。因為 HTTP 是無狀態的,所以瀏覽器使用 Cookie 來儲存 sessionId。下次客戶端會在傳送的請求中會攜帶 sessionId 值,服務端發現 sessionId 存在並以此為索引獲取使用者存在服務端的認證資訊進行認證操作。認證過則會提供資源訪問。

我們在Spring Security 實戰乾貨:登入後返回 JWT Token 一文其實也是通過 Form 提交來獲取 Jwt 其實 JwtsessionId 同樣的作用,只不過 Jwt 天然攜帶了使用者的一些資訊,而 sessionId 需要去進一步獲取使用者資訊。

2.5 Json Web Token 的認證方式 Bearer Authentication

我們通過表單認證獲取 Json Web Token ,那麼如何使用它呢? 通常我們會把 Jwt 作為令牌使用 Bearer Authentication 方式使用。Bearer Authentication 是一種基於令牌的 HTTP 身份驗證方案,使用者向伺服器請求訪問受限資源時,會攜帶一個 Token 作為憑證,檢驗通過則可以訪問特定的資源。最初是在 RFC 6750 中作為 OAuth 2.0 的一部分,但有時也可以單獨使用。
我們在使用 Bear Token 的方法是在請求頭的 Authorization 欄位中放入 Bearer <token> 的格式的加密串(Json Web Token)。請注意 Bearer 字首與 Token 之間有一個空字元位,與基本身份驗證類似,Bearer Authentication 只能在HTTPS(SSL)上使用。

3. Spring Security 中實現介面 Jwt 認證

接下來我們是我們該系列的重頭戲 ———— 介面的 Jwt 認證。

3.1 定義 Json Web Token 過濾器

無論上面提到的哪種認證方式,我們都可以使用 Spring Security 中的 Filter 來處理。 Spring Security 預設的基礎配置沒有提供對 Bearer Authentication 處理的過濾器, 但是提供了處理 Basic Authentication 的過濾器:

org.springframework.security.web.authentication.www.BasicAuthenticationFilter

BasicAuthenticationFilter 繼承了 OncePerRequestFilter 。所以我們也模仿 BasicAuthenticationFilter 來實現自己的 JwtAuthenticationFilter 。 完整程式碼如下:

 package cn.felord.spring.security.filter;
 
 import cn.felord.spring.security.exception.SimpleAuthenticationEntryPoint;
 import cn.felord.spring.security.jwt.JwtTokenGenerator;
 import cn.felord.spring.security.jwt.JwtTokenPair;
 import cn.felord.spring.security.jwt.JwtTokenStorage;
 import cn.hutool.json.JSONArray;
 import cn.hutool.json.JSONObject;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.HttpHeaders;
 import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
 import org.springframework.security.authentication.BadCredentialsException;
 import org.springframework.security.authentication.CredentialsExpiredException;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.AuthorityUtils;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
 import org.springframework.util.StringUtils;
 import org.springframework.web.filter.OncePerRequestFilter;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.util.List;
 import java.util.Objects;
 
 /**
  * jwt 認證攔截器 用於攔截 請求 提取jwt 認證
  *
  * @author dax
  * @since 2019/11/7 23:02
  */
 @Slf4j
 public class JwtAuthenticationFilter extends OncePerRequestFilter {
     private static final String AUTHENTICATION_PREFIX = "Bearer ";
     /**
      * 認證如果失敗由該端點進行響應
      */
     private AuthenticationEntryPoint authenticationEntryPoint = new SimpleAuthenticationEntryPoint();
     private JwtTokenGenerator jwtTokenGenerator;
     private JwtTokenStorage jwtTokenStorage;
 
 
     public JwtAuthenticationFilter(JwtTokenGenerator jwtTokenGenerator, JwtTokenStorage jwtTokenStorage) {
         this.jwtTokenGenerator = jwtTokenGenerator;
         this.jwtTokenStorage = jwtTokenStorage;
     }
 
 
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
         // 如果已經通過認證
         if (SecurityContextHolder.getContext().getAuthentication() != null) {
             chain.doFilter(request, response);
             return;
         }
         // 獲取 header 解析出 jwt 並進行認證 無token 直接進入下一個過濾器  因為  SecurityContext 的緣故 如果無許可權並不會放行
         String header = request.getHeader(HttpHeaders.AUTHORIZATION);
         if (StringUtils.hasText(header) && header.startsWith(AUTHENTICATION_PREFIX)) {
             String jwtToken = header.replace(AUTHENTICATION_PREFIX, "");
 
 
             if (StringUtils.hasText(jwtToken)) {
                 try {
                     authenticationTokenHandle(jwtToken, request);
                 } catch (AuthenticationException e) {
                     authenticationEntryPoint.commence(request, response, e);
                 }
             } else {
                 // 帶安全頭 沒有帶token
                 authenticationEntryPoint.commence(request, response, new AuthenticationCredentialsNotFoundException("token is not found"));
             }
 
         }
         chain.doFilter(request, response);
     }
 
     /**
      * 具體的認證方法  匿名訪問不要攜帶token
      * 有些邏輯自己補充 這裡只做基本功能的實現
      *
      * @param jwtToken jwt token
      * @param request  request
      */
     private void authenticationTokenHandle(String jwtToken, HttpServletRequest request) throws AuthenticationException {
 
         // 根據我的實現 有效token才會被解析出來
         JSONObject jsonObject = jwtTokenGenerator.decodeAndVerify(jwtToken);
 
         if (Objects.nonNull(jsonObject)) {
             String username = jsonObject.getStr("aud");
 
             // 從快取獲取 token
             JwtTokenPair jwtTokenPair = jwtTokenStorage.get(username);
             if (Objects.isNull(jwtTokenPair)) {
                 if (log.isDebugEnabled()) {
                     log.debug("token : {}  is  not in cache", jwtToken);
                 }
                 // 快取中不存在就算 失敗了
                 throw new CredentialsExpiredException("token is not in cache");
             }
             String accessToken = jwtTokenPair.getAccessToken();
 
             if (jwtToken.equals(accessToken)) {
                   // 解析 許可權集合  這裡
                 JSONArray jsonArray = jsonObject.getJSONArray("roles");
 
                 String roles = jsonArray.toString();
 
                 List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(roles);
                 User user = new User(username, "[PROTECTED]", authorities);
                 // 構建使用者認證token
                 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, authorities);
                 usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                 // 放入安全上下文中
                 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
             } else {
                 // token 不匹配
                 if (log.isDebugEnabled()){
                     log.debug("token : {}  is  not in matched", jwtToken);
                 }
 
                 throw new BadCredentialsException("token is not matched");
             }
         } else {
             if (log.isDebugEnabled()) {
                 log.debug("token : {}  is  invalid", jwtToken);
             }
             throw new BadCredentialsException("token is invalid");
         }
     }
 }

具體看程式碼註釋部分,邏輯有些地方根據你業務進行調整。匿名訪問必然是不能帶 Token 的!

3.2 配置 JwtAuthenticationFilter

首先將過濾器 JwtAuthenticationFilter 注入 Spring IoC 容器 ,然後一定要將 JwtAuthenticationFilter 順序置於 UsernamePasswordAuthenticationFilter 之前:

        @Override
         protected void configure(HttpSecurity http) throws Exception {
             http.csrf().disable()
                     .cors()
                     .and()
                     // session 生成策略用無狀態策略
                     .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                     .and()
                     .exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
                     .and()
                     .authorizeRequests().anyRequest().authenticated()
                     .and()
                     .addFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)
                     // jwt 必須配置於 UsernamePasswordAuthenticationFilter 之前
                     .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                     // 登入  成功後返回jwt token  失敗後返回 錯誤資訊
                     .formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
                     .and().logout().addLogoutHandler(new CustomLogoutHandler()).logoutSuccessHandler(new CustomLogoutSuccessHandler());
 
         }

4. 使用 Jwt 進行請求驗證

編寫一個受限介面 ,我們這裡是 http://localhost:8080/foo/test 。直接請求會被 401 。 我們通過下圖方式獲取 Token :

然後在 Postman 中使用 Jwt :

最終會認證成功並訪問到資源。

5. 重新整理 Jwt Token

我們在 Spring Security 實戰乾貨:手把手教你實現JWT Token 中已經實現了 Json Web Token 都是成對出現的邏輯。accessToken 用來介面請求, refreshToken 用來重新整理 accessToken 。我們可以同樣定義一個 Filter 可參照 上面的 JwtAuthenticationFilter 。只不過 這次請求攜帶的是 refreshToken,我們在過濾器中攔截 URI跟我們定義的重新整理端點進行匹配。同樣驗證 Token ,通過後像登入成功一樣返回 Token 對即可。這裡不再進行程式碼演示。

6. 總結

這是系列原創文章,總有不仔細看的同學抓不著頭腦頗有微詞。飯需要一口一口的吃,沒有現成的可以吃,都是這麼過來的,急什麼。原創不易,關注才是動力。Spring Security 實戰乾貨系列 每一篇都有不同的知識點,而且它們都是相互有聯絡的。有不懂的地方多回頭看。Spring Security 並不難學,關鍵是你找對思路了沒有。本次 DEMO 可通過關注公眾號:Felordcn 回覆 ss08 獲取。

關注公眾號:Felordcn 獲取更多資訊

個人部落格:https://felord.cn

相關文章