跳到主要內容

探索 Spring Security 6 +JWT Authentication

 前言


{探 Spring Security(二) 用戶帳號資料儲存於DB} 文章中,我們將立了一個 Spring Security 專案,並將使用者相關權限存於DB。

透過這篇文章,我們將Spring Security 與 JWT 結合,改使用JWT認證機制,處理身份驗證和授權功能。

有關於 JWT,本篇就省略該部分的介紹,請各位可以直接過去看看(JSON Web Token)



實作

步驟一:新增依賴 (Dependencies)

在您的 pom.xml 中,新增 Nimbus JWT 庫的依賴。

<dependencies>

    <dependency>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-security</artifactId>

    </dependency>


    <dependency>

        <groupId>com.nimbusds</groupId>

        <artifactId>nimbus-jose-jwt</artifactId>

        <version>9.31</version> 

    </dependency>


    <dependency>

        <groupId>org.springframework.security</groupId>

        <artifactId>spring-security-crypto</artifactId>

    </dependency>

</dependencies>


步驟二:配置 JWT 相關的 Bean

我們需要一個秘密金鑰來簽名和驗證 JWT。最安全的方式是使用 SecretKey,而不是直接使用純文本字符串。

1. JwtConfig.java (金鑰配置)

@Configuration

public class JwtConfig {


    // 建議將此金鑰從設定檔載入

    @Value("${jwt.secret:default-secret-key-32-bytes-long}")

    private String secretKeyString;


    /**

     * 建立 HMAC SHA-256 簽章演算法所需的 SecretKey。

     * * @return 用於簽章/驗證的 SecretKey

     */

    @Bean

    public SecretKey jwtSecretKey() {

        // 確保金鑰長度至少為 256 位元(32 位元組)

        byte[] keyBytes = secretKeyString.getBytes(StandardCharsets.UTF_8);

        if (keyBytes.length < 32) {

            throw new IllegalArgumentException("金鑰長度至少為 256 位元.");

        }

        return new SecretKeySpec(keyBytes, "HmacSHA256");

    }


    /**

     * 建立 JWS 簽署器 (Signer)

     */

    @Bean

    public JWSVerifier jwsVerifier(SecretKey secretKey) throws JOSEException {

        return new MACVerifier(secretKey);

    }


    /**

     * 建立 JWS 驗證器 (Verifier)

     */

    @Bean

    public JWSSigner jwsSigner(SecretKey secretKey) throws JOSEException {

        return new MACSigner(secretKey);

    }

}



步驟三:建立 JwtService 服務

這個服務負責 JWT 的核心邏輯:產生 Token 和驗證/解析 Token。

1. JwtService.java


@Service

public class JwtService {


    private final JWSSigner signer;

    private final JWSVerifier verifier;

    private final SecretKey secretKey;


    // Token 的有效期限(例如:24小時 86400000 毫秒, 1小時 3600000 毫秒)

    @Value("${jwt.expiration-ms:3600000}")

    private long jwtExpirationMs;


    @Autowired

    public JwtProvider(JWSSigner signer, JWSVerifier verifier, SecretKey secretKey) {

        this.signer = signer;

        this.verifier = verifier;

        this.secretKey = secretKey;

    }


    /**

     * 1. 根據 Authentication 物件產生 JWT

     */

    public String generateToken(Authentication authentication) {

        CustomUserDetails userPrincipal = (CustomUserDetails) authentication.getPrincipal();


        Date now = new Date();

        Date expiryDate = new Date(now.getTime() + jwtExpirationMs);


        // 設定 Claims (負載)

        JWTClaimsSet claims = new JWTClaimsSet.Builder()

                .subject(userPrincipal.getUsername()) // Sub: 使用者名稱

                .issueTime(now) // Iat: 簽發時間

                .expirationTime(expiryDate) // Exp: 過期時間

                .claim("uid", userPrincipal.getUsername())// 自訂 Claim: 使用者ID

                .build();


        // 建立 JWS 頭部 (使用 HMAC SHA-256)

        JWSHeader header = new JWSHeader(JWSAlgorithm.HS256);


        // 建立簽名 JWS 對象

        SignedJWT signedJWT = new SignedJWT(header, claims);


        try {

            // 簽名

            signedJWT.sign(signer);

            return signedJWT.serialize();

        } catch (JOSEException e) {

            // 應該要記錄日誌

            throw new RuntimeException("Error signing JWT", e);

        }

    }


    /**

     * 2. 從 JWT 取得用戶名

     */

    public String getUsernameFromToken(String token) {

        try {

            SignedJWT signedJWT = SignedJWT.parse(token);

            return signedJWT.getJWTClaimsSet().getSubject();

        } catch (ParseException e) {

            // 應該要記錄日誌

            return null;

        }

    }


    /**

     * 3. 驗證 Token 有效性

     */

    public boolean validateToken(String authToken) {

        try {

            SignedJWT signedJWT = SignedJWT.parse(authToken);


            // 驗證簽名

            if (!signedJWT.verify(verifier)) {

                return false;

            }


            // 驗證時間戳記 (過期時間)

            Date now = new Date();

            Date expirationTime = signedJWT.getJWTClaimsSet().getExpirationTime();

            if (expirationTime != null && expirationTime.before(now)) {

                return false;

            }


            return true;

        } catch (ParseException | JOSEException e) {

            // 令牌格式錯誤或簽章驗證失敗

            return false;

        }

    }

}


步驟四:建立 JwtAuthenticationFilter

這個過濾器負責攔截每個請求,從 Header 中提取 Token 並設定 Spring Security 上下文。

1. JwtAuthenticationFilter.java

public class JwtAuthenticationFilter extends OncePerRequestFilter {


    @Autowired

    private JwtService tokenService;


    @Autowired

    private CustomUserDetailsService customUserDetailsService;


    // 從請求頭取得 Token 的方法

    private String getJwtFromRequest(HttpServletRequest request) {

        String bearerToken = request.getHeader("Authorization");

        // 檢查是否包含 Bearer 前綴

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {

            return bearerToken.substring(7); // 擷取 Token

        }

        return null;

    }


    @Override

    protected void doFilterInternal(HttpServletRequest request,

            HttpServletResponse response,

            FilterChain filterChain) throws ServletException, IOException {

        try {

            String jwt = getJwtFromRequest(request);


            if (StringUtils.hasText(jwt) && tokenService.validateToken(jwt)) {


                String username = tokenService.getUsernameFromToken(jwt);


                // 從資料庫載入用戶

                UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);


                // 建立 Authentication 對象

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(

                        userDetails, null, userDetails.getAuthorities());


                // 設定詳細信息,通常是請求的 IP、Session ID 等

                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));


                // 將 Authentication 物件設定到 SecurityContext 中

                SecurityContextHolder.getContext().setAuthentication(authentication);

            }

        } catch (Exception ex) {

            logger.error("Could not set user authentication in security context", ex);

        }


        filterChain.doFilter(request, response);

    }

}


步驟五:更新 SecurityConfig (整合過濾器)

您需要調整 SecurityConfig 來停用 Session,並新增您的 JWT 濾鏡。

1. SecurityConfig.java

@Configuration

@EnableWebSecurity

@EnableMethodSecurity // Enables @PreAuthorize

public class SecurityConfig {


    // JWT Filter

    @Bean

    public JwtAuthenticationFilter jwtAuthenticationFilter() {

        return new JwtAuthenticationFilter();

    }


    // 設定 AuthenticationManager,用於處理登入要求

    @Bean

    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)

            throws Exception {

        return authenticationConfiguration.getAuthenticationManager();

    }


    @Bean

    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http

                .csrf(AbstractHttpConfigurer::disable)


                // 停用 Session 管理,使用 STATELESS

                .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))


                .authorizeHttpRequests(auth -> auth

                        // 允許所有人存取登入接口

                        .requestMatchers("/auth/**", "/login").permitAll()


                        // 其他受保護的 RESTful 接口

                        .requestMatchers(HttpMethod.GET, "/api/users", "/api/users/{uid}").hasAnyAuthority("read")

                        .requestMatchers(HttpMethod.POST, "/api/user").hasAnyAuthority("create")

                        .requestMatchers(HttpMethod.PUT, "/api/users/{uid}").hasAnyAuthority("update")

                        .requestMatchers(HttpMethod.DELETE, "/api/users/{uid}").hasAnyAuthority("delete", "ROLE_ADMIN")


                        .anyRequest().authenticated())


                // Spring Security 預設的 UsernamePasswordAuthenticationFilter 之前新增 JWT 過濾器

                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);


        return http.build();

    }


    @Bean

    public PasswordEncoder passwordEncoder() {

        return new BCryptPasswordEncoder();

    }


}


步驟六:更新 AuthController (處理登錄)

您需要一個新的控制器來接收使用者名稱和密碼,進行認證,並在成功後返回 JWT。

1. 登入請求 DTO (LoginRequest.java)


public class LoginRequest {

    private String username;

    private String password;

    // Getters and Setters

}


2. AuthController.java

@RestController

@RequestMapping("/auth")

public class AuthController {


    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private JwtService tokenService;


    @PostMapping("/login")

    public ResponseEntity<String> authenticateUser(@RequestBody LoginRequest loginRequest) {


        // 1. 使用 AuthenticationManager 驗證憑證

        Authentication authentication = authenticationManager.authenticate(

                new UsernamePasswordAuthenticationToken(

                        loginRequest.getUsername(),

                        loginRequest.getPassword()));


        // 2. 認證成功後,將 Authentication 物件設定到 SecurityContext 中 (可選,但推薦)

        SecurityContextHolder.getContext().setAuthentication(authentication);


        // 3. 產生 JWT Token

        String jwt = tokenService.generateToken(authentication);


        // 回傳 Token 給客戶端

        return ResponseEntity.ok(jwt);

    }

}


測試

使用 Postman:

1. 認證 (Login):

 * Method: POST

 * URL: http://localhost:8088/auth/login

 * Body: application/json

2. JSON{

3. "username": "admin",

4. "password": "password"

5. }

6.

預期回應: 狀態碼 200 OK,回應體中包含產生的 JWT 字串。















測試驗證 (使用 Postman)

方法: GET

URL: http://localhost:8088/api/users

Authorization: Bearer <您在步驟 1 中獲得的 JWT>

預期結果:

如果認證成功: 您將收到狀態碼 200 OK 和使用者清單。

如果認證失敗: 您將收到狀態碼 401 Unauthorized

























方法: GET

URL: http://localhost:8088/api/users/2

預期結果:

如果認證成功: 您將收到狀態碼 200 OK 和該ID使用者清單。

如果認證失敗: 您將收到狀態碼 401 Unauthorized

























文章結束

留言

這個網誌中的熱門文章

初探 Vue 呼叫 API 出現 CORS 跨來源資源共享 問題原因

提要:   在 {初探Vue 與 Spring boot 的對話} 專案 ,前端 Vue 應用程式 串接 後端 API 伺服器 ,axios 呼叫 API 時出現以下,”無法取得回應內容 (No 'Access-Control-Allow-Origin' header is present on the requested resource):” 錯誤訊息,根據查找相關資料 ,出現以下原因。 瀏覽器開發工具 錯誤訊息 畫面 錯誤原因: “ Access to XMLHttpRequest at ” from origin ‘http://localhost:8080’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource” 瀏覽器為了安全考量,實施了同源政策。 當您的前端應用程式 (http://localhost:8080) 嘗試呼叫一個不同來源 (不同協議、不同域名或不同埠號) 的 API 伺服器 (http://localhost:8088) 時,瀏覽器會主動阻止這個請求,除非伺服器明確地允許這個跨來源的存取。 同源政策限制(Same-Origin Policy): 同源政策限制了程式碼和不同網域資源間的互動,同源是指兩份網頁具備相同協定、埠號(如果有指定)以及主機位置 範例: 表列哪些 URL 與 URL http://www.example.com/api/p1 屬於同源: URL                                                   | 結果   | 原因 --------------------------------------------------------------------- http://www.example.com/api/p2     |...

初探 Vue 與 Spring boot 的對話之Frontend (Vue-Frontend)

  Front-end Vue 使用 REST API 建立 Vite 專案 可參考 { Vue 3 初探}  文章 danny@Danny-Yu projects % npm create vite@latest Need to install the following packages: create-vite@8.2.0 Ok to proceed? (y) y > npx > "create-vite" │ ◇   Project name: │   vue-frontend │ ◇   Select a framework: │   Vue │ ◇   Select a variant: │   TypeScript │ ◇   Use rolldown-vite (Experimental)?: │   No │ ◇   Install with npm and start now? │   Yes │ ◇   Scaffolding project in /Users/danny/Desktop/projects/vue-frontend... │ ◇   Installing dependencies with npm... added 47 packages, and audited 48 packages in 27s 6 packages are looking for funding   run `npm fund` for details found 0 vulnerabilities │ ◇   Starting dev server... > vue-frontend@0.0.0 dev > vite   VITE v7.2.4   ready in 411 ms   ➜   Local:   http://localhost:5173/   ➜   Network: use --host to expose   ➜   press h + enter to show...

初探 Spring 中的循環依賴

原因: 當兩個或多個 bean 直接或間接地相互依賴時, 就會出現 Circular Dependency (循環依賴) 如: Bean A -> Bean B -> Bean A import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class BeanA {          @Autowired     private BeanB beanB;     public String sayHi() {         return "Hi! 我是 Class A.";     } } import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class BeanB {          @Autowired     private BeanA beanA;     public String sayHi() {         return "Hi! 我是 Class B.";     } } 編譯時不會出現問題 danny@Danny-Yu demo % mvn clean install -Dmaven.test.skip=true                           ... ... [INFO] Installing /Users/danny/Desktop/projects/demo/target/dem...