一文搞懂Session和JWT登入認證

weiweiyi發表於2023-04-15

前言

目前在開發的小組結課專案中用到了JWT認證,簡單分享一下,並看看與Session認證的異同。

登入認證(Authentication)的概念非常簡單,就是透過一定手段對使用者的身份進行確認。

我們都知道 HTTP 是無狀態的,伺服器接收的每一次請求,對它來說都是 “新來的”,並不知道客戶端來過。

舉個例子:
客戶端A: 我是A, 給我一瓶水。
服務端B: 好,給你。
客戶端A: 再給我來個麵包。
服務端B: 啥,你是誰?

即每一個請求對伺服器來說都是新的。

我們不可能每次操作都讓使用者輸入使用者名稱和密碼,那麼我們如何讓伺服器記住我們登入過了呢?

那就是憑證。即每次請求都給伺服器一個憑證告訴伺服器我是誰。

現在一般使用比較多的認證方式有四種:

  • Session
  • Token
  • SSO單點登入
  • OAtuth登入

下面就來說說比較常用的前兩種。

Session

Cookie + Session

最常見的就是 Cookie + Session 認證。

Session,是一種有狀態的會話管理機制,其目的就是為了解決HTTP無狀態請求帶來的問題。

當使用者登入認證請求透過時,服務端會將使用者的資訊儲存起來,並生成一個 SessionId 傳送給前端,前端將這個 SessionId 儲存起來。之後前端再傳送請求時都攜帶 SessionId,伺服器端再根據這個 SessionId 來檢查該使用者有沒有登入過。

這個 SessionId, 一般是儲存在Cookie中。

image.png

如果使用者第一次訪問某個伺服器時,伺服器響應資料時會在響應頭的 Set-Cookie 標識裡將Session Id 返回給瀏覽器,瀏覽器就將標識中的資料存在Cookie中。

下面我們來簡單寫個 demo 測試一下:

初始化一個spring boot 專案,並且程式碼如下:

demo

我們只需要在使用者登入的時候將使用者資訊存在HttpSession中

@RestController
public class UserController {

    @PostMapping("login")
    public String login(@RequestBody User user, HttpSession session) {
        if ("admin".equals(user.getUsername()) && "admin".equals(user.getPassword())) {
            // 登入成功 寫入Session
            session.setAttribute("sessionId", user);
            return "login success";
        }
        return "username or password incorrect";
    }

    @GetMapping("/logout")
    public String logout(HttpSession session) {
        // 登出 刪除Session
        session.removeAttribute("sessionId");
        return "logout success";
    }

    public String api(HttpSession session) {
        // 使用者操作 判斷是否登入
        User user = (User) session.getAttribute("sessionId");
        if (user == null) {
            return "please login";
        }
        return "return data";
    }
}

下面我們向 login 地址 請求,並檢視響應。 可以看到,使用者登入時服務端會返回 Set-Cookie 欄位。這些工作 Servlet幫我們做好了

image.png

下面我們向 api 地址請求。可以看到, 後續訪問服務端自動就會攜帶Cookie:

image.png

服務端認證身份成功,返回資料。
image.png


Session + Header認證

當前開發的幾個專案都是採用這種模式。

將 Session 會話放進 請求頭中作為認證資訊。

image.png

下面簡單寫個 demo 並用 postman 測試

demo

pom.xml:


        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session</artifactId>
            <version>1.3.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

HeaderAndParamHttpSessionStrategy:

將cookie認證改為Header認證, 請求關鍵字為 x-auth-token

/**
 * Header或是請求引數中的帶有 token 的認證策略
 * */
public class HeaderAndParamHttpSessionStrategy extends HeaderHttpSessionStrategy {
  /**
   * header認證關鍵字名稱
   */
  private String headerName = "x-auth-token";

  @Override
  public String getRequestedSessionId(HttpServletRequest request) {
    String token = request.getHeader(this.headerName);
    return (token != null && !token.isEmpty()) ? token : request.getParameter(this.headerName);
  }
}

MvcSecurityConfig:
使用spring 提供的 MapSessionRepository 來幫助我們管理Session

@Configuration
@EnableWebSecurity
@EnableSpringHttpSession
public class MvcSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 開放埠
                .antMatchers("**").permitAll()
                .anyRequest().authenticated()
                .and()
                .httpBasic()
                .and().cors()
                .and().csrf().disable();
        http.headers().frameOptions().disable();
        return http.build();
    }

    /**
     * 使用header認證來替換預設的cookie認證
     */
    @Bean
    public HttpSessionStrategy httpSessionStrategy() {
        return new HeaderAndParamHttpSessionStrategy();
    }


    /**
     * 由於我們啟用了@EnableSpringHttpSession後,而非RedisHttpSession.
     * 所以應該為SessionRepository提供一個實現。
     * 而Spring中預設給了一個SessionRepository的實現MapSessionRepository.
     *
     * @return session策略
     */
    @Bean
    public SessionRepository sessionRepository() {
        return new MapSessionRepository();
    }
}

重新啟動專案:

嘗試1: 向 login 地址請求:

可以看到,服務端的響應頭上帶有了 x-auth-token, 這種將 session 放在請求頭部的認證就是 header認證。

image.png

結果: 響應頭返回 x-auth-token 欄位


嘗試2: 請求頭不帶 x-auth-token, 向服務端請求 api

而這時候,假如客戶端傳送接下來的請求的時候,請求頭不帶上服務端返回的 x-auth-token,那麼是無法得到認證的。

image.png

結果: 認證失敗

嘗試3: 請求頭帶 x-auth-token, 向服務端請求 api

將登入成功之後,服務端返回

image.png

結果: 認證成功

透過以上的方式: 我們成功將 Cookie + Session 認證 ,替換為了 Header + Session 認證。

那麼換之後,有什麼好處呢?

Header + Session 相較 Cookie + Session 有幾點好處

  • 防止跨站指令碼攻擊(XSS):使用 Cookie 儲存會話 ID 的話,Cookie 是透過瀏覽器自動管理的,容易受到 XSS 攻擊的影響。而將會話 ID 儲存在頭部,可以避免這種攻擊。
  • 避免 CSRF 攻擊:使用 Cookie 儲存會話 ID 的話,攻擊者可以利用 CSRF 攻擊來獲取 Cookie 中的會話 ID,從而偽造使用者請求。將會話 ID 儲存在頭部的話,可以避免這種攻擊。
  • 不受第三方 Cookie 支援的限制:如果使用者的瀏覽器禁用了第三方 Cookie,那麼使用 Cookie + Session 的方式就無法使用。而將會話 ID 儲存在頭部,不需要使用 Cookie,不受這個限制。

缺點:

  • 會話 ID 儲存在頭部,可能被重放攻擊利用
  • 執行效能代價較高:由於 HTTP 頭比 Cookie 更大,因此將會話 ID 儲存在頭部通常會佔用更多的網路資源,增加傳輸延遲。

因此,應該根據具體的應用場景、協議、需求和安全要求來選擇合適的身份認證方式。

Token 認證

除了Session之外,目前比較流行的做法就是使用JWT(JSON Web Token)。

JWT具有以下倆種特性:

  • 可以將一段資料加密成一段字串,也可以從這字串解密回資料
  • 可以對這個字串進行校驗,比如有沒有過期,有沒有被篡改

image.png

看到這,這不和 Session + Header 認證一樣嘛!就是把 SessionId 換成了JWT字串而已,有必要麼??

Session 和 JWT有一個重要的區別,就是 Session 是有狀態的,JWT是無狀態的。

即,Session 在服務端儲存了使用者資訊,而JWT在服務端沒有儲存任何資訊。

當前端攜帶Session Id到服務端時,服務端要檢查其對應的 HttpSession 中有沒有儲存使用者資訊,儲存了就代表登入了。

當使用JWT時,服務端只需要對這個字串進行校驗,校驗透過就代表登入了。

下面繼續從一個Demo體驗:

demo

pom.xml:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

寫一個工具類

public interface CommonService {
  /**
   * 簽名秘鑰
   */
  String SECRET = "shareMusic";

  // 根據使用者id生成token
  static String createJwtToken(Long id) {
    long ttlMillis = -1; // 表示不新增過期時間
    return createJwtToken(id.toString(), ttlMillis);
  }

  /**
   * 生成Token
   *
   * @param id        編號
   * @param ttlMillis 簽發時間 (有效時間,過期會報錯)
   * @return token String
   */
  static String createJwtToken(String id, long ttlMillis) {

    // 簽名演算法 ,將對token進行簽名
    SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    // 生成簽發時間
    long nowMillis = System.currentTimeMillis();
    Date now = new Date(nowMillis);

    // 透過秘鑰簽名JWT
    byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SECRET);
    Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());

    // Let's set the JWT Claims
    JwtBuilder builder = Jwts.builder().setId(id)
        .setIssuedAt(now)
        .signWith(signatureAlgorithm, signingKey);

    // 如果指定了過期時間,則新增
    if (ttlMillis >= 0) {
      long expMillis = nowMillis + ttlMillis;
      Date exp = new Date(expMillis);
      builder.setExpiration(exp);
    }

    return builder.compact();

  }

    // 驗證並解析JWT
    static Claims parseJWT(String jwt) { // 如果是空字串直接返回null
        if (jwt == null ||jwt.isEmpty()) {
            return null;
        }
        // 這個Claims物件包含了許多屬性,比如簽發時間、過期時間以及存放的資料等
        Claims claims = null;
        // 解析失敗了會丟擲異常,所以我們要捕捉一下。token過期、token非法都會導致解析失敗
        try {

            claims = Jwts.parser()
                    .setSigningKey(DatatypeConverter.parseBase64Binary(SECRET))
                    .parseClaimsJws(jwt).getBody();
        } catch (JwtException e) {
            System.err.println("解析失敗!");
        }
        return claims;
    }
}

同時改寫一下我們的 Controller:
登入成功返回

    @PostMapping("login")
    public String login(@RequestBody User user, HttpServletResponse response) {
        if ("admin".equals(user.getUsername()) && "admin".equals(user.getPassword())) {
            // 判斷成功 返回頭加入token
            String token = CommonService.createJwtToken(user.getUsername());
            response.setHeader("Authorization", token);
            return "login success";
        }
        return "username or password incorrect";
    }

    @GetMapping("/api")
    public String api(HttpServletRequest request) {
        String jwt = request.getHeader("Authorization");
        // 解析失敗就提示使用者登入
        if (CommonService.parseJWT(jwt) == null) {
            return "please login";
        }
        return "return data";
    }

重啟服務開始測試。

嘗試1: 向 login 地址請求.

可以看到,我們成功地在服務端響應頭中返回了 JWT,它是以 Authorization 作為名字的欄位。

image.png

嘗試2: 不帶JWT, 向 api 地址請求.

認證失敗。

image.png

嘗試3: 帶JWT, 向 api 地址請求.

image.png

結果: 認證成功, 返回資料。


上面我們成功使用JWT完成了登入和請求api。下面簡單看一下它的原理:

JWT原理:

JWT 通常由三部分組成:Header、Payload 和 Signature。

  • Header:包含 Token 型別(即 JWT)和所使用的簽名演算法資訊(如 HS256)。
  • Payload:儲存了一些描述資訊,如 Token 的頒發者、過期時間、訪問許可權等,也可以包含一些使用者的自定義資料。
  • Signature:由伺服器端生成,用於驗證 Token 的正確性和完整性。

image.png


我們將剛才獲取到的JWT去線上網站解密一下:
可以看到,獲取到了我們傳輸的資訊: 使用者名稱

線上網站將 Header 和 Payload 中的 Base64 編碼資訊透過簡單的演算法將其還原成原始的明文資料

image.png

可以看到簽名是無法解密的,這是因為 JWT 的簽名主要是用於保證 JWT 的完整性和防止 JWT 被篡改 或偽造

signature 可以選擇對稱加密演算法或者非對稱加密演算法,常用的就是 HS256、RS256。

具體JWt解析過程可以看這篇文章: https://www.freecodecamp.org/chinese/news/how-to-sign-and-val...

JWT 登出

你可能會留意到, 上面的JWT方法沒有登出的功能。那麼如何登出??

事實上,JWT 是無狀態的認證方式,因此它本身並不提供登出的機制。讓我們從後臺登出token,這對於jwt來說並不是那麼簡單,並不能像刪除 session 那樣來刪除token。

JWT的目的與session不同,不可能強制刪除或失效已經生成的token。

我們可以採用下面兩種方式:

Token 過期⏰。

透過過期時間機制。可以在生成 JWT Token 時設定一個過期時間,一旦 Token 過期後則視為無效。透過這種方式,可以保證 Token 在一定時間內有效,同時也避免了 Token 濫用和被盜用的風險。

我還是想登出

假如我有一個嚴格的登出功能,無法等待Token自動過期怎麼辦??️‍

那麼儲存一個所謂的“名單”,判斷Token是有效的。一般可以採用 Redis,

校驗時,檢查提供的 token 在 redis 中是否有效,如何無效的話就讓使用者去登入。

從這個方面也體現出了 JWt 更適合分散式結構。

Session 和 JWT

兩者的不同

儲存位置:Session 資訊是儲存在服務端的,而 JWT 將認證資訊儲存在客戶端的 Token 中。

是否需要狀態:Session 基於狀態來維護會話,如果會話狀態丟失或者被篡改,伺服器將會重新初始化會話。而 JWT 身份認證機制是無狀態的,每個請求均包含足夠的資訊,伺服器不需要維持任何狀態。這一點使得 JWT 身份認證機制特別適合於分散式系統。

安全性:Session 是基於某種演算法生成的 Session ID 來維護使用者狀態的,如果 Session ID 被竊取或者偽造,會話會受到攻擊,憑證會失效。而 JWT 透過簽名來防止偽造和篡改,只有在經過驗證後才能使用。

擴充套件性:Session 方案一般適用於單一的服務或者單個應用,而 JWT 身份認證機制適用於跨域、分散式服務呼叫等多場景。

兩種方式都可以實現登入認證,至於具體選型就根據自己實際業務需求來了。


參考文章:
https://www.cnblogs.com/RudeCrab/p/14251154.html#%E6%94%B6%E5...
https://segmentfault.com/a/1190000041216780
https://cloud.tencent.com/developer/news/837117

相關文章