Spring Security - JWT



概述

JSON Web Token 或 JWT,更常見的是這樣稱呼,是一種開放的網際網路標準 (RFC 7519),用於以緊湊的方式安全地傳輸各方之間的可信資訊。令牌包含編碼為 JSON 物件的宣告,並使用私鑰或公鑰/私鑰對進行數字簽名。它們是自包含且可驗證的,因為它們經過數字簽名。JWT 可以被簽名和/或加密。簽名的令牌驗證令牌中包含的宣告的完整性,而加密的令牌則向其他方隱藏宣告。

JWT 也可以用於資訊交換,儘管它們更常用於授權,因為它們比使用記憶體中隨機令牌的會話管理具有許多優勢。其中最大的一點是能夠將身份驗證邏輯委派給第三方伺服器,例如AuthO 等。

JWT 令牌分為三個部分:標頭、有效負載和簽名,格式如下:

[Header].[Payload].[Signature]
  • 標頭 - JWT 標頭包含應用於 JWT 的密碼操作列表。這可以是簽名技術、關於內容型別的元資料資訊等等。標頭以 JSON 物件的形式呈現,並編碼為 base64URL。有效的 JWT 標頭的示例如下:

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

    這裡,“alg”提供關於所用演算法型別的資訊,“typ”提供資訊型別。

  • 有效負載 - JWT 的有效負載部分包含使用令牌傳輸的實際資料。這部分也稱為 JWT 令牌的“宣告”部分。宣告可以分為三種類型:註冊的、公共的和私有的。

  • 註冊的宣告是推薦但非強制性宣告,例如 iss(發行者)、sub(主題)、aud(受眾)等。

  • 公共宣告是由使用 JWT 的使用者定義的宣告。

  • 私有宣告或自定義宣告是為在相關各方之間共享資訊而建立的使用者定義宣告。

    有效負載物件的示例可能是:

    { "sub": "12345", "name": "Johnny Hill", "admin": false }
    

    有效負載物件與標頭物件一樣,也進行 base64Url 編碼,此字串構成 JWT 的第二部分。

  • 簽名 - JWT 的簽名部分用於驗證訊息在傳輸過程中是否未被更改。如果令牌使用私鑰簽名,它還驗證傳送者是否是它聲稱的那個人。它是使用編碼的標頭、編碼的有效負載、金鑰和標頭中指定的演算法建立的。簽名的示例如下:

    HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
    

如果我們把標頭、有效負載和簽名放在一起,我們會得到如下所示的令牌。

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6I
kpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlfQ.gWDlJdpCTIHVYKkJSfAVNUn0ZkAjMxskDDm-5Fhe
WJ7xXgW8k5CllcGk4C9qPrfa1GdqfBrbX_1x1E39JY8BYLobAfAg1fs_Ky8Z7U1oCl6HL63yJq_
wVNBHp49hWzg3-ERxkqiuTv0tIuDOasIdZ5FtBdtIP5LM9Oc1tsuMXQXCGR8GqGf1Hl2qv8MCyn
NZJuVdJKO_L3WGBJouaTpK1u2SEleVFGI2HFvrX_jS2ySzDxoO9KjbydK0LNv_zOI7kWv-gAmA
j-v0mHdJrLbxD7LcZJEGRScCSyITzo6Z59_jG_97oNLFgBKJbh12nvvPibHpUYWmZuHkoGvuy5RLUA

現在,此令牌可用於使用 Bearer 模式在 Authorization 標頭中,如Authorization - Bearer <token>

JWT 令牌用於授權是最常見的應用。令牌通常在伺服器中生成併發送到客戶端,並在會話儲存或本地儲存中儲存。為了訪問受保護的資源,客戶端會在標頭中傳送 JWT,如上所示。我們將在下面的部分中看到 Spring Security 中的 JWT 實現。

讓我們開始使用 Spring Security 進行實際程式設計。在開始使用 Spring 框架編寫您的第一個示例之前,您必須確保已正確設定 Spring 環境,如Spring Security - 環境搭建章節中所述。我們還假設您對 Spring Tool Suite IDE 有點了解。

現在讓我們繼續編寫一個基於 Spring MVC 的應用程式,該應用程式由 Maven 管理,它將要求使用者登入、驗證使用者身份,然後使用 Spring Security 表單登入功能提供登出選項。

使用 Spring Initializr 建立專案

Spring Initializr 是開始 Spring Boot 專案的好方法。它提供了一個易於使用的使用者介面來建立專案、新增依賴項、選擇 Java 執行時等。它生成一個骨架專案結構,下載後可以在 Spring Tool Suite 中匯入,然後我們可以繼續使用我們的現成專案結構。

我們選擇一個 Maven 專案,將專案命名為 formlogin,Java 版本為 21。新增以下依賴項:

  • Spring Web

  • Spring Security

  • Thymeleaf

  • Spring Boot DevTools

Spring Initializr

Thymeleaf 是 Java 的模板引擎。它允許我們快速開發靜態或動態網頁以在瀏覽器中呈現。它具有極高的可擴充套件性,允許我們詳細定義和自定義模板的處理。此外,我們可以點選此連結瞭解更多關於 Thymeleaf 的資訊。

讓我們繼續生成專案並下載它。然後我們將其解壓縮到我們選擇的資料夾中,並使用任何 IDE 開啟它。我將使用Spring Tools Suite 4。它可以從https://springframework.tw/tools網站免費下載,並針對 Spring 應用程式進行了最佳化。

包含所有相關依賴項的 pom.xml

讓我們看一下我們的 pom.xml 檔案。它應該看起來類似於這樣:

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <parent>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-parent</artifactId>
      <version>3.3.1</version>
      <relativePath/> <!-- lookup parent from repository -->
   </parent>
   <groupId>com.tutorialspoint.security</groupId>
   <artifactId>formlogin</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>formlogin</name>
   <description>Demo project for Spring Boot</description>
   <url/>
   <licenses>
      <license/>
   </licenses>
   <developers>
      <developer/>
   </developers>
   <scm>
      <connection/>
      <developerConnection/>
      <tag/>
      <url/>
   </scm>
   <properties>
      <java.version>21</java.version>
   </properties>
   <dependencies>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-security</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-thymeleaf</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
         <groupId>org.thymeleaf.extras</groupId>
         <artifactId>thymeleaf-extras-springsecurity6</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-devtools</artifactId>
         <scope>runtime</scope>
         <optional>true</optional>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-test</artifactId>
         <scope>test</scope>
      </dependency>
      <dependency>
         <groupId>org.springframework.security</groupId>
         <artifactId>spring-security-test</artifactId>
         <scope>test</scope>
      </dependency>
      <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt-api</artifactId>
         <version>0.11.5</version>
      </dependency>
      <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt-impl</artifactId>
         <version>0.11.5</version>
         <scope>runtime</scope>
      </dependency>
      <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt-jackson</artifactId>
         <version>0.11.5</version>
         <scope>runtime</scope>
      </dependency>
   </dependencies>
   <build>
      <plugins>
         <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
         </plugin>
      </plugins>
   </build>
</project>

JWT 金鑰

JWT 包含一個金鑰,我們將在我們的 application.properties 檔案中定義,如下所示。

application.properties

spring.application.name=formlogin
secret=somerandomsecretsomerandomsecretsomerandomsecretsomerandomsecret

JWT 相關類

現在讓我們建立一個名為 jwtutils 的包。此包將包含與 JWT 操作相關的所有類和介面,其中包括:

  • 生成令牌
  • 驗證令牌
  • 檢查簽名
  • 驗證宣告和許可權

在這個包中,我們建立了我們的第一個名為 TokenManager 的類。此類將負責使用 io.jsonwebtoken.Jwts 建立和驗證令牌。

TokenManager.java

package com.tutorialspoint.security.formlogin.jwtutils;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;

@Component
public class TokenManager {

   private static final long serialVersionUID = 7008375124389347049L; 
   public static final long TOKEN_VALIDITY = 10 * 60 * 60; 

   @Value("${secret}") 
   private String jwtSecret; 

   // Generates a token on successful authentication by the user 
   // using username, issue date of token and the expiration date of the token.
   public String generateJwtToken(UserDetails userDetails) { 
      Map<String, Object> claims = new HashMap<>(); 
      return Jwts
         .builder()
         .setClaims(claims)  // set the claims
         .setSubject(userDetails.getUsername())  // set the username as subject in payload
         .setIssuedAt(new Date(System.currentTimeMillis()))
         .setExpiration(new Date(System.currentTimeMillis() + TOKEN_VALIDITY * 1000))
         .signWith(getKey(), SignatureAlgorithm.HS256)  // signature part
         .compact();
   }

   // Validates the token 
   // Checks if user is an authenticatic one and using the token is the one that was generated and sent to the user. 
   // Token is parsed for the claims such as username, roles, authorities, validity period etc.
   public Boolean validateJwtToken(String token, UserDetails userDetails) { 
      final String username = getUsernameFromToken(token);
      final Claims claims = Jwts
         .parserBuilder()
         .setSigningKey(getKey())
         .build()
         .parseClaimsJws(token).getBody(); 
      Boolean isTokenExpired = claims.getExpiration().before(new Date());
      return (username.equals(userDetails.getUsername())) && !isTokenExpired;
   }

   // get the username by checking subject of JWT Token
   public String getUsernameFromToken(String token) {
      final Claims claims = Jwts
         .parserBuilder()
         .setSigningKey(getKey())
         .build()
         .parseClaimsJws(token).getBody(); 
      return claims.getSubject(); 
   }
   
   // create a signing key based on secret
   private Key getKey() {
      byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);		
      Key key = Keys.hmacShaKeyFor(keyBytes);
      return key;
   }
}

JwtUserDetailsService.java

package com.tutorialspoint.security.formlogin.jwtutils;

import java.util.ArrayList; 
import org.springframework.security.core.userdetails.User; 
import org.springframework.security.core.userdetails.UserDetails; 
import org.springframework.security.core.userdetails.UserDetailsService; 
import org.springframework.security.core.userdetails.UsernameNotFoundException; 
import org.springframework.stereotype.Service; 

@Service
public class JwtUserDetailsService implements UserDetailsService { 
   @Override 
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      // create a user for "randomuser123"/"password".
      if ("randomuser123".equals(username)) { 
         return new User("randomuser123", // username
            "$2a$10$slYQmyNdGzTn7ZLBXBChFOC9f6kFjAqPhccnP6DxlWXx2lPk1C3G6", // encoded password
            new ArrayList<>()); 
      } else { 
         throw new UsernameNotFoundException("User not found with username: " + username); 
      } 
   } 
}

現在是時候建立我們的過濾器了。過濾器類將用於跟蹤我們的請求並檢測它們是否在標頭中包含有效的令牌。如果令牌有效,我們允許請求繼續,否則我們傳送 401 錯誤(未授權)。

JwtFilter.java

package com.tutorialspoint.security.formlogin.jwtutils;

import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.ExpiredJwtException;


// filter to run for every request
@Component
public class JwtFilter extends OncePerRequestFilter {
   @Autowired
   private JwtUserDetailsService userDetailsService;
   @Autowired
   private TokenManager tokenManager;
   @Override
   protected void doFilterInternal(HttpServletRequest request,
      HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

      String tokenHeader = request.getHeader("Authorization");
      String username = null;
      String token = null;
      // if bearer token is provided, get the username 	  
      if (tokenHeader != null && tokenHeader.startsWith("Bearer ")) {
         token = tokenHeader.substring(7);
         try {
            username = tokenManager.getUsernameFromToken(token);
         } catch (IllegalArgumentException e) {
            System.out.println("Unable to get JWT Token");
         } catch (ExpiredJwtException e) {
            System.out.println("JWT Token has expired");
         }
      } else {
         System.out.println("Bearer String not found in token");
      }
      // validate the JWT Token and create a new authentication token and set in security context	  
      if (null != username && SecurityContextHolder.getContext().getAuthentication() == null) {
         UserDetails userDetails = userDetailsService.loadUserByUsername(username);
         if (tokenManager.validateJwtToken(token, userDetails)) {
            UsernamePasswordAuthenticationToken
               authenticationToken = new UsernamePasswordAuthenticationToken(
                  userDetails, null, userDetails.getAuthorities());
               authenticationToken.setDetails(new
                  WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
         }
      }
      filterChain.doFilter(request, response);
   }
}

建立了請求過濾器後,我們現在建立 JwtAutheticationEntryPoint 類。此類擴充套件了 Spring 的 AuthenticationEntryPoint 類,並拒絕所有未經身份驗證的請求,並向客戶端傳送錯誤程式碼 401。我們已重寫 AuthenticationEntryPoint 類的 commence() 方法來執行此操作。

JwtAuthenticationEntryPoint.java

package com.tutorialspoint.security.formlogin.jwtutils;

import java.io.IOException;
import java.io.Serializable;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

   private static final long serialVersionUID = 1L;

   @Override
   public void commence(HttpServletRequest request, HttpServletResponse response, 
      AuthenticationException authException) throws IOException, ServletException {
      response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
   }
}

接下來,我們在 models 包下為我們的請求和響應模型建立類。這些模型確定我們的請求和響應格式將如何進行身份驗證。

JwtRequestModel.java

package com.tutorialspoint.security.formlogin.jwtutils.models;

import java.io.Serializable; 
public class JwtRequestModel implements Serializable { 
   /** 
   * 
   */ 
   private static final long serialVersionUID = 2636936156391265891L; 
   private String username; 
   private String password; 
   public JwtRequestModel() { 
   } 
   public JwtRequestModel(String username, String password) { 
      super(); 
      this.username = username; this.password = password; 
   } 
   public String getUsername() { 
      return username;
   } 
   public void setUsername(String username) { 
      this.username = username; 
   } 
   public String getPassword() { 
      return password; 
   } 
   public void setPassword(String password) { 
      this.password = password; 
   } 
}

JwtResponseModel.java

package com.tutorialspoint.security.formlogin.jwtutils.models;

import java.io.Serializable; 
public class JwtResponseModel implements Serializable {
   /**
   *
   */
   private static final long serialVersionUID = 1L;
   private final String token;
   public JwtResponseModel(String token) {
      this.token = token;
   }
   public String getToken() {
      return token;
   }
}

現在,我們正在建立一個控制器類,以便在使用者使用POST /login呼叫登入後建立 JWT 令牌。

JwtController.java

package com.tutorialspoint.security.formlogin.jwtutils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.tutorialspoint.security.formlogin.jwtutils.models.JwtRequestModel;
import com.tutorialspoint.security.formlogin.jwtutils.models.JwtResponseModel;

@RestController
@CrossOrigin
public class JwtController {
   @Autowired
   private JwtUserDetailsService userDetailsService;
   @Autowired
   private AuthenticationManager authenticationManager;
   @Autowired
   private TokenManager tokenManager;
   
   // Get a JWT Token once user is authenticated, otherwise throw BadCredentialsException
   @PostMapping("/login")
   public ResponseEntity<JwtResponseModel> createToken(@RequestBody JwtRequestModel
      request) throws Exception {
      try {
         authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
      } catch (DisabledException e) {
         throw new Exception("USER_DISABLED", e);
      } catch (BadCredentialsException e) {
         throw new Exception("INVALID_CREDENTIALS", e);
      }
      final UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
      final String jwtToken = tokenManager.generateJwtToken(userDetails);
      return ResponseEntity.ok(new JwtResponseModel(jwtToken));
   }
}

Spring Security 配置類

在我們的 config 包中,我們建立了 WebSecurityConfig 類。我們將使用此類進行我們的安全配置,因此讓我們使用 @Configuration 註解和 @EnableWebSecurity 對其進行註釋。結果,Spring Security 知道將此類視為配置類。正如我們所看到的,Spring 使配置應用程式變得非常容易。

WebSecurityConfig

package com.tutorialspoint.security.formlogin.config; 

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.tutorialspoint.security.formlogin.jwtutils.JwtAuthenticationEntryPoint;
import com.tutorialspoint.security.formlogin.jwtutils.JwtFilter; 

@Configuration 
@EnableWebSecurity
public class WebSecurityConfig {

   @Autowired
   private JwtAuthenticationEntryPoint authenticationEntryPoint;
   @Autowired
   private JwtFilter filter;

   @Bean 
   protected PasswordEncoder passwordEncoder() { 
      return new BCryptPasswordEncoder(); 
   }

   @Bean
   protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 
      return http
         .csrf(AbstractHttpConfigurer::disable)
         .authorizeHttpRequests(request -> request.requestMatchers("/login").permitAll()
         .anyRequest().authenticated())
         // Send a 401 error response if user is not authentic.		 
         .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint))
         // no session management
         .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 
         // filter the request and add authentication token		 
         .addFilterBefore(filter,  UsernamePasswordAuthenticationFilter.class)
         .build();
   }

   @Bean
   AuthenticationManager customAuthenticationManager() {
      return authentication -> new UsernamePasswordAuthenticationToken("randomuser123","password");
   }
}

控制器類

在這個類中,我們為單個 GET "/hello" 端點建立了一個對映。

HelloController

package com.tutorialspoint.security.formlogin.controllers;

import org.springframework.web.bind.annotation.GetMapping; 
import org.springframework.web.bind.annotation.RestController; 

@RestController 
public class HelloController {
   @GetMapping("/hello") 
   public String hello() { 
      return "hello"; 
   } 
}

輸出

正如我們所看到的,我們已經完成了所有這些工作,現在我們的應用程式可以使用了。讓我們啟動應用程式並使用 postman 發出請求。

Postman Body

在這裡,我們發出了第一個請求以獲取令牌,正如我們所看到的,在提供正確的使用者名稱/密碼組合後,我們得到了令牌。

現在在我們的標頭中使用該令牌,讓我們呼叫 /hello 端點。

Postman Authorization Body

正如我們所看到的,由於請求已透過身份驗證,我們獲得了所需的響應。現在,如果我們篡改令牌或不傳送 Authorization 標頭,我們將獲得 401 錯誤,這在我們應用程式中進行了配置。這確保了我們使用 JWT 保護請求。

Postman Authorization Body
廣告
© . All rights reserved.