Spring Security 實(shí)戰(zhàn)干貨:使用 JWT 認(rèn)證訪問(wèn)接口

1. 前言

歡迎閱讀Spring Security 實(shí)戰(zhàn)干貨系列。之前我講解了如何編寫一個(gè)自己的 Jwt 生成器以及如何在用戶認(rèn)證通過(guò)后返回 Json Web Token 。今天我們來(lái)看看如何在請(qǐng)求中使用 Jwt 訪問(wèn)鑒權(quán)。DEMO 獲取方法在文末。

2. 常用的 Http 認(rèn)證方式

我們要在 Http 請(qǐng)求中使用 Jwt 我們就必須了解 常見的 Http 認(rèn)證方式。

2.1 HTTP Basic Authentication

HTTP Basic Authentication 又叫基礎(chǔ)認(rèn)證,它簡(jiǎn)單地使用 Base64 算法對(duì)用戶名、密碼進(jìn)行加密,并將加密后的信息放在請(qǐng)求頭 Header 中,本質(zhì)上還是明文傳輸用戶名、密碼,并不安全,所以最好在 Https 環(huán)境下使用。其認(rèn)證流程如下:

basic.png

客戶端發(fā)起 GET 請(qǐng)求 服務(wù)端響應(yīng)返回 401 Unauthorized, www-Authenticate 指定認(rèn)證算法,realm 指定安全域。然后客戶端一般會(huì)彈窗提示輸入用戶名稱和密碼,輸入用戶名密碼后放入 Header 再次請(qǐng)求,服務(wù)端認(rèn)證成功后以 200 狀態(tài)碼響應(yīng)客戶端。

2.2 HTTP Digest Authentication

為彌補(bǔ) BASIC 認(rèn)證存在的弱點(diǎn)就有了 HTTP Digest Authentication 。它又叫摘要認(rèn)證。它使用隨機(jī)數(shù)加上 MD5 算法來(lái)對(duì)用戶名、密碼進(jìn)行摘要編碼,流程類似 Http Basic Authentication ,但是更加復(fù)雜一些:

步驟1:跟基礎(chǔ)認(rèn)證一樣,只不過(guò)返回帶 WWW-Authenticate 首部字段的響應(yīng)。該字段內(nèi)包含質(zhì)問(wèn)響應(yīng)方式認(rèn)證所需要的臨時(shí)咨詢碼(隨機(jī)數(shù),nonce)。 首部字段WWW-Authenticate 內(nèi)必須包含 realmnonce 這兩個(gè)字段的信息。客戶端就是依靠向服務(wù)器回送這兩個(gè)值進(jìn)行認(rèn)證的。nonce 是一種每次隨返回的 401 響應(yīng)生成的任意隨機(jī)字符串。該字符串通常推薦由 Base64 編碼的十六進(jìn)制數(shù)的組成形式,但實(shí)際內(nèi)容依賴服務(wù)器的具體實(shí)現(xiàn)

步驟2:接收到 401 狀態(tài)碼的客戶端,返回的響應(yīng)中包含 DIGEST 認(rèn)證必須的首部字段 Authorization 信息。首部字段 Authorization 內(nèi)必須包含username、realm、nonce、uriresponse 的字段信息,其中,realmnonce 就是之前從服務(wù)器接收到的響應(yīng)中的字段。

步驟3:接收到包含首部字段 Authorization 請(qǐng)求的服務(wù)器,會(huì)確認(rèn)認(rèn)證信息的正確性。認(rèn)證通過(guò)后則會(huì)返回包含 Request-URI 資源的響應(yīng)。

并且這時(shí)會(huì)在首部字段 Authorization-Info 寫入一些認(rèn)證成功的相關(guān)信息。

2.3 SSL 客戶端認(rèn)證

SSL 客戶端認(rèn)證就是通常我們說(shuō)的 HTTPS 。安全級(jí)別較高,但需要承擔(dān) CA 證書費(fèi)用。SSL 認(rèn)證過(guò)程中涉及到一些重要的概念,數(shù)字證書機(jī)構(gòu)的公鑰、證書的私鑰和公鑰、非對(duì)稱算法(配合證書的私鑰和公鑰使用)、對(duì)稱密鑰、對(duì)稱算法(配合對(duì)稱密鑰使用)。相對(duì)復(fù)雜一些這里不過(guò)多講述。

2.4 Form 表單認(rèn)證

Form 表單的認(rèn)證方式并不是HTTP規(guī)范。所以實(shí)現(xiàn)方式也呈現(xiàn)多樣化,其實(shí)我們平常的掃碼登錄,手機(jī)驗(yàn)證碼登錄都屬于表單登錄的范疇。表單認(rèn)證一般都會(huì)配合 Cookie,Session 的使用,現(xiàn)在很多 Web 站點(diǎn)都使用此認(rèn)證方式。用戶在登錄頁(yè)中填寫用戶名和密碼,服務(wù)端認(rèn)證通過(guò)后會(huì)將 sessionId 返回給瀏覽器端,瀏覽器會(huì)保存 sessionId 到瀏覽器的 Cookie 中。因?yàn)?HTTP 是無(wú)狀態(tài)的,所以瀏覽器使用 Cookie 來(lái)保存 sessionId。下次客戶端會(huì)在發(fā)送的請(qǐng)求中會(huì)攜帶 sessionId 值,服務(wù)端發(fā)現(xiàn) sessionId 存在并以此為索引獲取用戶存在服務(wù)端的認(rèn)證信息進(jìn)行認(rèn)證操作。認(rèn)證過(guò)則會(huì)提供資源訪問(wèn)。

我們?cè)?a >Spring Security 實(shí)戰(zhàn)干貨:登錄后返回 JWT Token 一文其實(shí)也是通過(guò) Form 提交來(lái)獲取 Jwt 其實(shí) JwtsessionId 同樣的作用,只不過(guò) Jwt 天然攜帶了用戶的一些信息,而 sessionId 需要去進(jìn)一步獲取用戶信息。

2.5 Json Web Token 的認(rèn)證方式 Bearer Authentication

我們通過(guò)表單認(rèn)證獲取 Json Web Token ,那么如何使用它呢? 通常我們會(huì)把 Jwt 作為令牌使用 Bearer Authentication 方式使用。Bearer Authentication 是一種基于令牌的 HTTP 身份驗(yàn)證方案,用戶向服務(wù)器請(qǐng)求訪問(wèn)受限資源時(shí),會(huì)攜帶一個(gè) Token 作為憑證,檢驗(yàn)通過(guò)則可以訪問(wèn)特定的資源。最初是在 RFC 6750 中作為 OAuth 2.0 的一部分,但有時(shí)也可以單獨(dú)使用。
我們?cè)谑褂?Bear Token 的方法是在請(qǐng)求頭的 Authorization 字段中放入 Bearer <token> 的格式的加密串(Json Web Token)。請(qǐng)注意 Bearer 前綴與 Token 之間有一個(gè)空字符位,與基本身份驗(yàn)證類似,Bearer Authentication 只能在HTTPS(SSL)上使用。

3. Spring Security 中實(shí)現(xiàn)接口 Jwt 認(rèn)證

接下來(lái)我們是我們?cè)撓盗械闹仡^戲 ———— 接口的 Jwt 認(rèn)證。

3.1 定義 Json Web Token 過(guò)濾器

無(wú)論上面提到的哪種認(rèn)證方式,我們都可以使用 Spring Security 中的 Filter 來(lái)處理。 Spring Security 默認(rèn)的基礎(chǔ)配置沒(méi)有提供對(duì) Bearer Authentication 處理的過(guò)濾器, 但是提供了處理 Basic Authentication 的過(guò)濾器:

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

BasicAuthenticationFilter 繼承了 OncePerRequestFilter 。所以我們也模仿 BasicAuthenticationFilter 來(lái)實(shí)現(xiàn)自己的 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 認(rèn)證攔截器 用于攔截 請(qǐng)求 提取jwt 認(rèn)證
  *
  * @author dax
  * @since 2019/11/7 23:02
  */
 @Slf4j
 public class JwtAuthenticationFilter extends OncePerRequestFilter {
     private static final String AUTHENTICATION_PREFIX = "Bearer ";
     /**
      * 認(rèn)證如果失敗由該端點(diǎn)進(jìn)行響應(yīng)
      */
     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 {
         // 如果已經(jīng)通過(guò)認(rèn)證
         if (SecurityContextHolder.getContext().getAuthentication() != null) {
             chain.doFilter(request, response);
             return;
         }
         // 獲取 header 解析出 jwt 并進(jìn)行認(rèn)證 無(wú)token 直接進(jìn)入下一個(gè)過(guò)濾器  因?yàn)? SecurityContext 的緣故 如果無(wú)權(quán)限并不會(huì)放行
         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 {
                 // 帶安全頭 沒(méi)有帶token
                 authenticationEntryPoint.commence(request, response, new AuthenticationCredentialsNotFoundException("token is not found"));
             }
 
         }
         chain.doFilter(request, response);
     }
 
     /**
      * 具體的認(rèn)證方法  匿名訪問(wèn)不要攜帶token
      * 有些邏輯自己補(bǔ)充 這里只做基本功能的實(shí)現(xiàn)
      *
      * @param jwtToken jwt token
      * @param request  request
      */
     private void authenticationTokenHandle(String jwtToken, HttpServletRequest request) throws AuthenticationException {
 
         // 根據(jù)我的實(shí)現(xiàn) 有效token才會(huì)被解析出來(lái)
         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)) {
                   // 解析 權(quán)限集合  這里
                 JSONArray jsonArray = jsonObject.getJSONArray("roles");
 
                 String roles = jsonArray.toString();
 
                 List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(roles);
                 User user = new User(username, "[PROTECTED]", authorities);
                 // 構(gòu)建用戶認(rèn)證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");
         }
     }
 }

具體看代碼注釋部分,邏輯有些地方根據(jù)你業(yè)務(wù)進(jìn)行調(diào)整。匿名訪問(wèn)必然是不能帶 Token 的!

3.2 配置 JwtAuthenticationFilter

首先將過(guò)濾器 JwtAuthenticationFilter 注入 Spring IoC 容器 ,然后一定要將 JwtAuthenticationFilter 順序置于 UsernamePasswordAuthenticationFilter 之前:

        @Override
         protected void configure(HttpSecurity http) throws Exception {
             http.csrf().disable()
                     .cors()
                     .and()
                     // session 生成策略用無(wú)狀態(tài)策略
                     .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  失敗后返回 錯(cuò)誤信息
                     .formLogin().loginProcessingUrl(LOGIN_PROCESSING_URL).successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
                     .and().logout().addLogoutHandler(new CustomLogoutHandler()).logoutSuccessHandler(new CustomLogoutSuccessHandler());
 
         }

4. 使用 Jwt 進(jìn)行請(qǐng)求驗(yàn)證

編寫一個(gè)受限接口 ,我們這里是 http://localhost:8080/foo/test 。直接請(qǐng)求會(huì)被 401 。 我們通過(guò)下圖方式獲取 Token :

然后在 Postman 中使用 Jwt :

最終會(huì)認(rèn)證成功并訪問(wèn)到資源。

5. 刷新 Jwt Token

我們?cè)?Spring Security 實(shí)戰(zhàn)干貨:手把手教你實(shí)現(xiàn)JWT Token 中已經(jīng)實(shí)現(xiàn)了 Json Web Token 都是成對(duì)出現(xiàn)的邏輯。accessToken 用來(lái)接口請(qǐng)求, refreshToken 用來(lái)刷新 accessToken 。我們可以同樣定義一個(gè) Filter 可參照 上面的 JwtAuthenticationFilter 。只不過(guò) 這次請(qǐng)求攜帶的是 refreshToken,我們?cè)谶^(guò)濾器中攔截 URI跟我們定義的刷新端點(diǎn)進(jìn)行匹配。同樣驗(yàn)證 Token ,通過(guò)后像登錄成功一樣返回 Token 對(duì)即可。這里不再進(jìn)行代碼演示。

6. 總結(jié)

這是系列原創(chuàng)文章,總有不仔細(xì)看的同學(xué)抓不著頭腦頗有微詞。飯需要一口一口的吃,沒(méi)有現(xiàn)成的可以吃,都是這么過(guò)來(lái)的,急什么。原創(chuàng)不易,關(guān)注才是動(dòng)力。Spring Security 實(shí)戰(zhàn)干貨系列 每一篇都有不同的知識(shí)點(diǎn),而且它們都是相互有聯(lián)系的。有不懂的地方多回頭看。Spring Security 并不難學(xué),關(guān)鍵是你找對(duì)思路了沒(méi)有。本次 DEMO 可通過(guò)關(guān)注公眾號(hào):Felordcn 回復(fù) ss08 獲取。


歡迎關(guān)注:碼農(nóng)小胖哥