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

jwt.png

1. 前言

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

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

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

2.1 HTTP Basic Authentication

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

basic.png

客戶端發(fā)起 GET 請求 服務(wù)端響應(yīng)返回 401 Unauthorized坝咐, www-Authenticate 指定認(rèn)證算法,realm 指定安全域匙姜。然后客戶端一般會彈窗提示輸入用戶名稱和密碼畅厢,輸入用戶名密碼后放入 Header 再次請求,服務(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 算法來對用戶名袖肥、密碼進(jìn)行摘要編碼咪辱,流程類似 Http Basic Authentication ,但是更加復(fù)雜一些:

步驟1:跟基礎(chǔ)認(rèn)證一樣椎组,只不過返回帶 WWW-Authenticate 首部字段的響應(yīng)油狂。該字段內(nèi)包含質(zhì)問響應(yīng)方式認(rèn)證所需要的臨時咨詢碼(隨機(jī)數(shù),nonce)寸癌。 首部字段WWW-Authenticate 內(nèi)必須包含 realmnonce 這兩個字段的信息专筷。客戶端就是依靠向服務(wù)器回送這兩個值進(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 請求的服務(wù)器,會確認(rèn)認(rèn)證信息的正確性。認(rèn)證通過后則會返回包含 Request-URI 資源的響應(yīng)堡纬。

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

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

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

2.4 Form 表單認(rèn)證

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

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

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

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

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

接下來我們是我們該系列的重頭戲 ———— 接口的 Jwt 認(rèn)證鹃觉。

3.1 定義 Json Web Token 過濾器

無論上面提到的哪種認(rèn)證方式专酗,我們都可以使用 Spring Security 中的 Filter 來處理。 Spring Security 默認(rèn)的基礎(chǔ)配置沒有提供對 Bearer Authentication 處理的過濾器盗扇, 但是提供了處理 Basic Authentication 的過濾器:

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

BasicAuthenticationFilter 繼承了 OncePerRequestFilter 祷肯。所以我們也模仿 BasicAuthenticationFilter 來實(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)證攔截器 用于攔截 請求 提取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)通過認(rèn)證
         if (SecurityContextHolder.getContext().getAuthentication() != null) {
             chain.doFilter(request, response);
             return;
         }
         // 獲取 header 解析出 jwt 并進(jìn)行認(rèn)證 無token 直接進(jìn)入下一個過濾器  因?yàn)? SecurityContext 的緣故 如果無權(quán)限并不會放行
         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);
     }
 
     /**
      * 具體的認(rè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才會被解析出來
         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)整佑笋。匿名訪問必然是不能帶 Token 的!

3.2 配置 JwtAuthenticationFilter

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

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

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

編寫一個受限接口 蒋纬,我們這里是 http://localhost:8080/foo/test 。直接請求會被 401 坚弱。 我們通過下圖方式獲取 Token :

然后在 Postman 中使用 Jwt :

最終會認(rèn)證成功并訪問到資源蜀备。

5. 刷新 Jwt Token

我們在 Spring Security 實(shí)戰(zhàn)干貨:手把手教你實(shí)現(xiàn)JWT Token 中已經(jīng)實(shí)現(xiàn)了 Json Web Token 都是成對出現(xiàn)的邏輯。accessToken 用來接口請求史汗, refreshToken 用來刷新 accessToken 琼掠。我們可以同樣定義一個 Filter 可參照 上面的 JwtAuthenticationFilter 。只不過 這次請求攜帶的是 refreshToken停撞,我們在過濾器中攔截 URI跟我們定義的刷新端點(diǎn)進(jìn)行匹配瓷蛙。同樣驗(yàn)證 Token ,通過后像登錄成功一樣返回 Token 對即可戈毒。這里不再進(jìn)行代碼演示艰猬。

6. 總結(jié)

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

關(guān)注公眾號:碼農(nóng)小胖哥盗飒,獲取更多資訊

個人博客:https://felord.cn

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末嚷量,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子逆趣,更是在濱河造成了極大的恐慌蝶溶,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,692評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件汗贫,死亡現(xiàn)場離奇詭異身坐,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)落包,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,482評論 3 392
  • 文/潘曉璐 我一進(jìn)店門部蛇,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人咐蝇,你說我怎么就攤上這事涯鲁。” “怎么了有序?”我有些...
    開封第一講書人閱讀 162,995評論 0 353
  • 文/不壞的土叔 我叫張陵抹腿,是天一觀的道長。 經(jīng)常有香客問我旭寿,道長警绩,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,223評論 1 292
  • 正文 為了忘掉前任盅称,我火速辦了婚禮肩祥,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘缩膝。我一直安慰自己混狠,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,245評論 6 388
  • 文/花漫 我一把揭開白布疾层。 她就那樣靜靜地躺著将饺,像睡著了一般。 火紅的嫁衣襯著肌膚如雪痛黎。 梳的紋絲不亂的頭發(fā)上予弧,一...
    開封第一講書人閱讀 51,208評論 1 299
  • 那天,我揣著相機(jī)與錄音湖饱,去河邊找鬼桌肴。 笑死,一個胖子當(dāng)著我的面吹牛琉历,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,091評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼旗笔,長吁一口氣:“原來是場噩夢啊……” “哼彪置!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起蝇恶,我...
    開封第一講書人閱讀 38,929評論 0 274
  • 序言:老撾萬榮一對情侶失蹤拳魁,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后撮弧,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體潘懊,經(jīng)...
    沈念sama閱讀 45,346評論 1 311
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,570評論 2 333
  • 正文 我和宋清朗相戀三年贿衍,在試婚紗的時候發(fā)現(xiàn)自己被綠了授舟。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,739評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡贸辈,死狀恐怖释树,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情擎淤,我是刑警寧澤奢啥,帶...
    沈念sama閱讀 35,437評論 5 344
  • 正文 年R本政府宣布,位于F島的核電站嘴拢,受9級特大地震影響桩盲,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜席吴,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,037評論 3 326
  • 文/蒙蒙 一赌结、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧抢腐,春花似錦姑曙、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,677評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至啼染,卻和暖如春宴合,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背迹鹅。 一陣腳步聲響...
    開封第一講書人閱讀 32,833評論 1 269
  • 我被黑心中介騙來泰國打工卦洽, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留阀蒂,地道東北人。 一個月前我還...
    沈念sama閱讀 47,760評論 2 369
  • 正文 我出身青樓酗失,卻偏偏與公主長得像,于是被迫代替她去往敵國和親昧绣。 傳聞我的和親對象是個殘疾皇子规肴,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,647評論 2 354

推薦閱讀更多精彩內(nèi)容