JWT認證瞭解和實踐

前面講解過什麼是SSO,OAuth2相關的一系列的知識點,今天講解一下JWT的相關知識。

一、JWT是什麼

JWT的全稱爲Json Web Token (JWT),是目前最流行的跨域認證解決方案,是爲了在網絡應用環境間傳遞聲明而執行的一種基於JSON的開放標準((RFC 7519)。而且該Token被設計爲緊湊且安全的,特別適用於分佈式站點的單點登錄(SSO)場景。我們在前面講解過SSO,知道通過CAS去實現SSO的應用是比較重和龐大的,而通過JWT去實現SSO不失爲一個好的方案。

JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該Token也可直接被用於認證,也可被加密。更多詳情也可以參考JWT的官網地址

二、與傳統的Session認證的區別

既然使用JWT那麼我們就要知道它與傳統的Session的區別,爲啥我們要使用它?帶着這個疑問我們來進一步探究。

a、傳統Session認證

因爲Http協議本身是一種無狀態的協議,因此這就意味着如果用戶嚮應用提供了用戶名和密碼來進行用戶認證,那麼下一次請求時,用戶還要再一次進行用戶認證才行,因爲根據Http協議,應用並不能知道是哪個用戶發出的請求,所以爲了讓我們的應用能識別是哪個用戶發出的請求,我們只能在服務器存儲一份用戶登錄的信息,這份登錄信息會在響應時傳遞給瀏覽器,保存爲Cookie,以便下次請求時發送給我們的應用,這樣我們的應用就能識別請求來自哪個用戶了,這就是傳統的基於Cookie-Session認證。

認證流程:
  1. 用戶輸入登錄信息
  2. 服務器驗證登錄信息是否正確,如果正確就創建一個Session,並把Session存入數據庫(一般是內存中)
  3. 服務器端會向客戶端返回帶有SessionId的Cookie
  4. 在接下來的請求中,服務器將把SessionId與數據庫(一般是內存中)中的相匹配,如果有效則處理該請求
  5. 如果用戶登出app,Session會在客戶端和服務器端都被銷燬
暴露的問題:
  • Session:Cookie+Session這種模式通常是保存在內存中,當客戶訪問量增加時,服務端就需要存儲大量的Session會話,而且服務從單服務到多服務會面臨的Session共享問題,隨着用戶量的增多,開銷就會越大。
  • CSRF: 因爲是基於Cookie來進行用戶識別的, Cookie如果被截獲,用戶就會很容易受到跨站請求僞造的攻擊。
  • 擴展性:對於集羣擴展需要通過採用緩存一致性技術來保證可以共享,或者採用第三方緩存來保存Session來解決Session在內存中的問題。

b、JWT認證

基於Token的身份驗證是無狀態的,服務器不需要記錄哪些用戶已經登錄或者哪些JWT已經處理。每個發送到服務器的請求都會帶上一個Token,服務器利用這個Token檢查確認請求的真實性。

Token通常以Bearer { JWT }的形式附加在已驗證的請求頭中,但是也可以用POST請求體或者問句參數進行傳遞。

認證流程:
  1. 用戶輸入登錄信息
  2. 服務器驗證登錄信息,如果正確就返回一個已簽名的Token
  3. 這個Token存儲在客戶端,最常見的是存儲在localStorage中,但是也可以存在Session Storage和Cookie中
  4. 之後向服務器發送的請求都會帶上這個Token
  5. 服務器解碼JWT,如果Token是有效的則處理這個請求
  6. 如果用戶退出登錄,Token會在客戶端銷燬,這一步與服務器無關

相對於傳統的Session認證方式,JWT天生支持無狀態,同時它也更更安全,通用性JSON,擴展強,支持跨域訪問等。

三、JWT的組成

JWT是由三段信息構成的,將這三段信息文本用.鏈接一起就構成了JWT字符串。如下所示:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.UQmqAUhUrpDVV2ST7mZKyLTomVfg7sYkEjmdDI5XF8Q

第一部分我們稱它爲頭部(header),第二部分我們稱其爲載荷(payload,類似於飛機上承載的物品),第三部分是簽證(signature)。

a、header(頭部)

JWT的頭部承載兩部分信息:

  • 聲明類型,這裏是JWT
  • 聲明加密的算法,通常直接使用 HMAC SHA256

完整的頭部就像下面這樣的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然後將頭部進行base64加密(該加密是可以對稱解密的),構成了第一部分

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
b、payload(載荷)

payload是JWT的組成部分的第二塊,載荷就是存放有效信息的地方。這個名字像是特指飛機上承載的貨品,這些有效信息包含三個部分:

  1. 標準中註冊的聲明
  2. 公共的聲明
  3. 私有的聲明
(1)、標準中註冊的聲明 (建議但不強制使用) :
  • iss: JWT簽發者
  • sub:JWT所面向的用戶
  • aud:接收JWT的一方
  • exp:JWT的過期時間,這個過期時間必須要大於簽發時間
  • nbf:定義在什麼時間之前,該JWT都是不可用的
  • iat:JWT的簽發時間
  • jti:JWT的唯一身份標識,主要用來作爲一次性Token,從而回避重放攻擊
(2)、公共的聲明 :

公共的聲明可以添加任何的信息,一般添加用戶的相關信息或其他業務需要的必要信息。但不建議添加敏感信息,因爲該部分在客戶端可解密。

(3)、私有的聲明 :

私有聲明是提供者和消費者所共同定義的聲明,一般不建議存放敏感信息,因爲base64是對稱解密的,意味着該部分信息可以歸類爲明文信息。

比如這裏我們定義一個payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然後將其進行base64加密,得到JWT的第二部分:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
c、signature(簽名)

JWT的第三部分是一個簽證信息,這個簽證信息由三部分組成:

  1. header (base64後的)
  2. payload (base64後的)
  3. secret

這個部分需要base64加密後的header和base64加密後的payload使用.連接組成的字符串(頭部在前),然後通過header中聲明的加密方式進行加鹽secret組合加密,然後就構成了JWT的第三部分。

UQmqAUhUrpDVV2ST7mZKyLTomVfg7sYkEjmdDI5XF8Q

密鑰secret是保存在服務端的,服務端會根據這個密鑰進行生成token和驗證,所以需要保護好。

d、簽名的目的

最後一步簽名的過程,實際上是對頭部以及載荷內容進行簽名。一般而言,加密算法對於不同的輸入產生的輸出總是不一樣的。對於兩個不同的輸入,產生同樣的輸出的概率極其地小。所以,我們就把“不一樣的輸入產生不一樣的輸出”當做必然事件來看待吧。

所以,如果有人對頭部以及載荷的內容解碼之後進行修改,再進行編碼的話,那麼新的頭部和載荷的簽名和之前的簽名就將是不一樣的。而且,如果不知道服務器加密的時候用的密鑰的話,得出來的簽名也一定會是不一樣的。

f、實際應用

一般是在請求頭裏加入Authorization,並加上Bearer標註:

fetch('api/user/1', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

四、在Spring中使用JWT

這裏主要講解一下在Spring中使用JWT,Java版本的JWT實現

除了上面的JWT的Java實現版本,還有其他版本的,比如java-jwt

首先引入依賴如下:

 <dependency>
     <groupId>io.jsonwebtoken</groupId>
     <artifactId>jjwt</artifactId>
     <version>0.9.0</version>
  </dependency>
        
 <dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
  </dependency>

在Spring中使用JWT主要使用兩種方式:

1、在Spring-MVC中通過自定義filter可以獲取到每次請求在請求攔截驗證JWT簽發的Token。

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		// 驗證token 
        //傳遞給後面的api
        filterChain.doFilter(request, response);
    }
}
   @Bean
   public FilterRegistrationBean jwtFilter() {
       FilterRegistrationBean registrationBean = new FilterRegistrationBean();
       JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
       registrationBean.setFilter(filter);
       return registrationBean;
   }

2、在Spring MVC中通過自定義HandlerInterceptor,在WebMvcConfigurer中進行配置。

public class BaseSecurityInterceptor extends HandlerInterceptorAdapter {

    private Logger logger = LoggerFactory.getLogger(BaseSecurityInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        super.preHandle(request, response, handler);
       // 驗證token 
        return true;
    }
}

說了那麼多,還是直接實戰吧,可以通過兩種方式來做,一種是通過白名單/黑名單進行過濾,另一種是通過在Rest請求層通過註解獲取/過濾特定註解。

首先創建一個Spring Boot應用JwtApplication,在config下面添加我們需要的配置JwtAuthenticationFilter,如下:

package net.anumbrella.spring.jwt.config;

import lombok.extern.slf4j.Slf4j;
import net.anumbrella.spring.jwt.util.JwtUtil;
import net.anumbrella.spring.jwt.util.ResponseUtil;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
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.Arrays;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import static java.util.stream.Collectors.toList;

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final PathMatcher PATH_MATCHER = new AntPathMatcher();

    private final static ConcurrentMap<String, Boolean> CACHE_IS_FILTER_PATH = new ConcurrentHashMap<>();

    private final List<String> jwtFilterWhitelist;

    private final List<String> jwtFilterBlacklist;


    public JwtAuthenticationFilter(JwtProperties jwtProperties) {
        this.jwtFilterWhitelist = Arrays.stream(jwtProperties.getJwtFilterWhitelist().split(",")).map(String::trim).collect(toList());
        this.jwtFilterBlacklist = Arrays.stream(jwtProperties.getJwtFilterBlacklist().split(",")).map(String::trim).collect(toList());
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        try {
            if (isFilterUrl(httpServletRequest)) {
                // 獲取請求頭信息authorization信息
                final String authHeader = httpServletRequest.getHeader(JwtUtil.AUTH_HEADER_KEY);
                log.info("## authHeader = {}", authHeader);
                if (StringUtils.isEmpty(authHeader) || !authHeader.startsWith(JwtUtil.TOKEN_PREFIX)) {
                    ResponseUtil.renderResponse(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "用戶未登錄,請先登錄");
                    return;
                }


                // 獲取token
                final String token = authHeader.substring(7);

                // 驗證token
                if(!JwtUtil.validateToken(token)){
                    ResponseUtil.renderResponse(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "token認證失敗,請重新登錄");
                }


                httpServletRequest = new RequestWrapper(httpServletRequest, JwtUtil.getUserId(token));
            }
        } catch (Exception e) {
            ResponseUtil.renderResponse(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "登陸已經失效,請重新登錄");
            return;
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }



    public boolean isFilterUrl(HttpServletRequest request) {
        String uri = request.getServletPath();
        if (CACHE_IS_FILTER_PATH.containsKey(uri)) {
            return CACHE_IS_FILTER_PATH.get(uri);
        }
        boolean flag = isFilter(uri);
        CACHE_IS_FILTER_PATH.putIfAbsent(uri, flag);
        return flag;
    }


    private boolean isFilter(String uri) {
        boolean filter = true;
        for (String backRegex : jwtFilterBlacklist) {
            if (urlMatching(backRegex, uri)) {
                return false;
            }
        }
        for (String regex : jwtFilterWhitelist) {
            filter = urlMatching(regex, uri);
            if (filter) {
                return true;
            }
        }
        return filter;
    }

    protected boolean urlMatching(String regex, String uri) {
        return PATH_MATCHER.match(regex, uri);
    }
}

添加JwtUtil工具類,如下:

package net.anumbrella.spring.jwt.util;


import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.util.Assert;

import java.time.LocalDateTime;

public class JwtUtil {

    public static final String AUTH_HEADER_KEY = "Authorization";


    public static final String TOKEN_PREFIX = "Bearer ";

    /**
     * JWT祕鑰
     */
    public static final String DEFAULT_SECRET = "secret";

    public static final String USER_ID = "userId";


    /**
     * 過期時間,一小時有效期
     */
    public static final LocalDateTime EXPIRE_TIME = LocalDateTime.now().plusHours(1);


    /**
     * 簽發JWT
     */
    public static String generateToken(String userId, String authInfo) {
        return generateToken(userId, authInfo, DEFAULT_SECRET);
    }

    /**
     * 簽發JWT
     */
    public static String generateToken(String userId, String authInfo, String secret) {

        return Jwts.builder()
                // 角色權限相關信息
                .claim(USER_ID, userId)
                .setIssuedAt(new java.util.Date())
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * 驗證JWT
     */
    public static Boolean validateToken(String token) {
        return validateToken(token, DEFAULT_SECRET);
    }


    /**
     * 驗證JWT
     */
    public static Boolean validateToken(String token, String secret) {
        try {
            return getClaimsFromToken(token, secret) != null;
        } catch (Exception e) {
            throw new IllegalStateException("Invalid Token. " + e.getMessage());
        }
    }

    /**
     * 從token中獲取用戶ID
     */
    public static String getUserId(String token) {
        return getUserId(token, DEFAULT_SECRET);
    }

    /**
     * 從token中獲取用戶ID
     */
    public static String getUserId(String token, String secret) {
        Claims claims = getClaimsFromToken(token, secret);
        return claims.get(USER_ID, String.class);
    }

    /**
     * 解析JWT
     */
    private static Claims getClaimsFromToken(String token, String secret) {
        Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
        return claims;
    }

}

在這裏我兩種方式FIlter和自定義HandlerInterceptor都添加了演示,在HandlerInterceptor中攔截特定忽略註解可以忽略到需要認證的接口。自己實際情況結合使用一種即可。

忽略註解JwtIgnore,如下:

package net.anumbrella.spring.jwt.annotation;

import java.lang.annotation.*;

@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtIgnore {
}

自定義HandlerInterceptor類BaseSecurityInterceptor,如下:

package net.anumbrella.spring.jwt.config;

import net.anumbrella.spring.jwt.annotation.JwtIgnore;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class BaseSecurityInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 忽略帶JwtIgnore註解的請求, 不做後續token認證校驗
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            JwtIgnore jwtIgnore = handlerMethod.getMethodAnnotation(JwtIgnore.class);
            if (jwtIgnore != null) {
                return true;
            }
        }
        return true;
    }
}

最後進行相關配置注入,如下:

package net.anumbrella.spring.jwt.config;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
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 DM
 */

@Configuration
@RequiredArgsConstructor
public class BaseMvcConfig implements WebMvcConfigurer {

    private final JwtProperties jwtProperties;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new BaseSecurityInterceptor());
    }


    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtProperties);
    }

    @Bean
    public FilterRegistrationBean jwtFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(jwtAuthenticationFilter());
        return registrationBean;
    }
}

到此JWT認證基本工作就完成了。接着新建一個UserRest進行測試,如下:

package net.anumbrella.spring.jwt.rest;

import com.google.gson.Gson;
import net.anumbrella.spring.jwt.model.UserDto;
import net.anumbrella.spring.jwt.util.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.groups.Default;

@RestController
@RequestMapping(value = "user")
public class UserRest {


    @PostMapping("/login")
    public ResponseEntity login(@RequestBody @Validated({PostMapping.class, Default.class}) UserDto userDto,
                                HttpServletResponse response) {
        // 獲取用戶ID
        Long userId = 1L;
        Gson gson = new Gson();
        String token = JwtUtil.generateToken(String.valueOf(userId), gson.toJson(userDto));
        // 將token放在響應頭
        response.setHeader(JwtUtil.AUTH_HEADER_KEY, JwtUtil.TOKEN_PREFIX + token);
        return ResponseEntity.ok("login success");
    }


    @GetMapping("/auth-info")
    public ResponseEntity authInfo(HttpServletRequest request) {
        String authHeader = request.getHeader(JwtUtil.AUTH_HEADER_KEY);
        String token = authHeader.substring(7);
        return ResponseEntity.ok(JwtUtil.getUserId(token));
    }

}

接着我們之間訪問接口可以發現提示認證信息:

auth-info

然後我們進行登錄,接着訪問auth-info接口,但是登錄接口我們必須先開放不用認證。

login

接着我們先進行登錄,如下:
login success

我們在header中獲取到返回頭信息,如下:

response header

最後在auth-info請求頭中加入認證Token信息即可。
在這裏插入圖片描述

五、擴展

我們知道在每次請求中都包含請求token,因此每次請求我們都能夠從request中獲取到保存在JWT中的信息,如果每次需要獲取信息,比如userId都要解析很麻煩,有沒有好的方法,其實我們可以通過HttpServletRequestWrapper自定義包裝一層request的請求。

RequestWrapper,如下:

package net.anumbrella.spring.jwt.config;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.util.Collections;
import java.util.Enumeration;

import static net.anumbrella.spring.jwt.util.JwtUtil.USER_ID;

public class RequestWrapper extends HttpServletRequestWrapper {

    private String userId;

    RequestWrapper(HttpServletRequest request, String userId) {
        super(request);
        this.userId = userId;
    }

    @Override
    public Enumeration<String> getHeaders(String name) {
        if (USER_ID.equals(name)) {
            return Collections.enumeration(Collections.singletonList(userId));
        }
        return super.getHeaders(name);
    }

    public String getUserId() {
        return userId;
    }

}

在認證成功後,包裝request返回後面請求鏈路。如下:

userId

當需要使用,在rest層添加@RequestHeader(value = USER_ID) Long userId即可。

    @GetMapping("/test")
    public ResponseEntity test(@RequestHeader(value = USER_ID) Long userId) {
        System.err.println(userId);
        return ResponseEntity.ok(userId);
    }

這裏再說明一下JWT和OAuth2的區別,因爲老是有同學把這個搞混:

JWT是一種認證協議

JWT提供了一種用於發佈接入令牌(Access Token),並對發佈的簽名接入令牌進行驗證的方法。 令牌(Token)本身包含了一系列聲明,應用程序可以根據這些聲明限制用戶對資源的訪問。

OAuth2是一種授權框架

另一方面,OAuth2是一種授權框架,提供了一套詳細的授權機制(指導)。用戶或應用可以通過公開的或私有的設置,授權第三方應用訪問特定資源。

簡單來說:應用場景不一樣

  1. OAuth2用在使用第三方賬號登錄的情況(比如使用weibo,qq,github登錄某個app)
  2. JWT是用在前後端分離, 需要簡單的對後臺API進行保護時使用.(前後端分離無session,頻繁傳用戶密碼不安全)

其次關於JWT和Cookie-Session,相關JWT也不是萬能的,對於用戶退出這種維護JWT就不好實現,需要去維護一個白名單或黑名單。因爲無狀態JWT一旦被生成,就不會再和服務端有任何瓜葛。一旦服務端中的相關數據更新,無狀態JWT中存儲的數據由於得不到更新,就變成了過期的數據。

JWT的最佳用途是一次性授權Token,這種場景下的Token的特性如下:

  • 有效期短
  • 只希望被使用一次

因此JWT不是萬能的,是否採用JWT,需要根據業務需求來確定。關於更多討論也有很多文章,可以參考
jwt 實踐以及與 session 對比JWT與Session的比較,以及國外的討論token-authentication-vs-cookies

代碼實例:Jwt

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章