iT邦幫忙

2024 iThome 鐵人賽

DAY 25
0
Software Development

關於我和 Spring Boot 變成家人的那件事系列 第 25

Day 25 - Spring Security (4) - JWT 驗證及結合 FilterChain

  • 分享至 

  • xImage
  •  

接續上一篇我們已經成功產生 JWT 回傳,所以後續使用者需要攜帶 JWT 至 Header 內然後發送請求到我們後端,我們需要驗證 JWT 是否有效然後決定使用者是否可以進入瀏覽或是使用功能,而這相對應的驗證流程,其實 Spring Security 有一套 FilterChain 的機制可以協助我們,這其中包含我們前面實作的帳號密碼驗證等等都是包含在裡面,那我們需要做的就是把 JWT 的驗證放入 FilterChain 裡面。

解析 Token 取出 Claims

把之前拿來產生 Token 的 密鑰( getKey() 產生的) 拿來解析 Token,extractAllClaims() 帶入 token 可以取出其中的 Claims 也就是 Payload 的部分。

因為 Payload 裡面有很多屬性,下面另外抽出各別取 username 使用者名稱, expiration 到期時間等等的方法,方便後續驗證時可以引用

@Service
@Slf4j
public class JwtService {
   // ......略

    public Claims extractAllClaims(String token) throws JwtException {
        return Jwts.parser()
        // 把之前 getKey() 取得的密鑰帶入來解析
                .setSigningKey(getKey())
                .build().parseClaimsJws(token).getBody();
    }
    
    public Claims extractAllClaims(String token) throws JwtException {
        return Jwts.parser()
                .setSigningKey(getKey())
                .build().parseClaimsJws(token).getBody();
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimResolver) {
        final Claims claims = extractAllClaims(token);
        return claimResolver.apply(claims);
    }

    public String extractUserName(String token) {
        // extract the username from jwt token
        return extractClaim(token, Claims::getSubject);
    }

    public boolean validateToken(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }
}

先寫個 Controller 確認一下解析內容 ,JWT 會放在 Header 下面 Authorization 的屬性內,@RequestHeader 可以幫忙取得 Header 的內容,然後需要切割掉前面的部分,他會是呈現這樣的格式 Bearer {JWT} 所以需要切掉前面Bearer 和一個空格,就是7個字元,切好之後就是 token 部分直接帶入給我們前面的方法

    @GetMapping("/extractJwt")
    public Map<String, Object> extractJwt(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
        String token = authorization.substring(7);
        try {
            return jwtService.extractAllClaims(token);
        } catch (JwtException e) {
            throw new BadCredentialsException(e.getMessage(), e);
        }
    }

實際用 postman 操作,用先前資料 sean 登入取得 JWT,然後用 postman 選擇 Authoriztion ⇒ Auth Type 選 Bearer Token 然後帶入 JWT 就可以看到回傳結果正確。

!https://ithelp.ithome.com.tw/upload/images/20240913/20150977UVI1FHxUjC.png

!https://ithelp.ithome.com.tw/upload/images/20240913/20150977UVI1FHxUjC.png

完成 JwtFilter

確定我們解析 token 沒問題就實作 JwtFilter 然後設置到 config 內。

這邊需要繼承 OncePerRequestFilter 這個 filter,他會協助每當請求來時只會進行一次驗證,否則 Spring Security 執行第一次後,因為該 Filter 剛好是個元件(bean),於是 Spring Boot 又執行第二次。

然後我們要 Override doFilterInternal() 這個方法,他會過濾請求,然後我們就照先前的解析方式,分別拿到 token 和 username 然後進行驗證,最後目標確認沒問題就會將其轉成 Authentication 物件,存入 Security Context,讓 Spring Security 的 FilterChain 進行驗證時可以取出確認。

下面的一些判斷都是確定請求內有帶對應的 header 才會動作

@Component
public class JwtFilter extends OncePerRequestFilter {

    @Autowired
    JwtService jwtService;

    @Autowired
    ApplicationContext context;

    @Autowired
    MyUserDetailsService myUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;

        try {
            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                token = authHeader.substring(7);
                username = jwtService.extractUserName(token);
            }
            
            // context 裡面沒東西才驗證 token,
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = myUserDetailsService.loadUserByUsername(username);
                
                // 檢查 userDetails 和 token 解析出來資訊相同且未過期
                if (jwtService.validateToken(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authtoken
                            = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authtoken);
                }
            }
        } catch (JwtException ex) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
        filterChain.doFilter(request, response);
    }
}

下面驗證 validateToken 部分會確認是否有過期,還有 token 解開的使用者名稱及 userDetails 內是相同,確認 JWT 是有效,然後才寫入 SecurityContext

config 加入 filter

前面實作完成,要加到 Spring Security 的 filter chain 中,認證的效果才能生效。

Spring Security 的 filter chain 會比其他 Filter 還優先執行。而裡頭負責進行的 Filter 叫做 UsernamePasswordAuthenticationFilter,正是負責帳號密碼認證。會選擇放在這個之前是因為,

JWT 認證通常是無狀態的,不依賴使用者名稱和密碼,如果JWT有效,就不需要再進行使用者名稱密碼認證。所以會加入在這個 filter 之前執行我們的 JwtFilter。

補充底下也添加 sessionManagement 選擇為無狀態,因為採用 Jwt 通常就不會採用 sesstion 來進行管理所以加入到配置中 Security 就不會幫我們產生 Session。

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtFilter jwtFilter;

    @Bean
    public AuthenticationProvider authProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(new BCryptPasswordEncoder(12));
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf(customizer -> customizer.disable())
                .authorizeHttpRequests((registry) -> registry
                        .requestMatchers(HttpMethod.POST, "/register", "/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/error", "/api/products/**", "/who-am-i").permitAll()   //指定路徑允許所有用戶訪問,不需身份驗證
                        .requestMatchers(HttpMethod.GET, "/checkAuthentication").hasAnyAuthority("ROLE_BUYER", "ROLE_SELLER", "ROLE_ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/products").hasAuthority("ROLE_SELLER")
                        .requestMatchers(HttpMethod.DELETE, "/api/products").hasAuthority("ROLE_SELLER")
                        .requestMatchers("/api/users/**").hasAuthority("ROLE_ADMIN") // 任何 /api/users 開頭的,且所有方法都算
    //                        .requestMatchers(HttpMethod.GET, "/api/users/?*").hasAuthority("ROLE_ADMIN") // 只有 /api/users/{id} 才算
                        .anyRequest().authenticated()//其他尚未匹配到的路徑都需要身份驗證
                )
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

最後進行測試,同樣拿 getUsers() 的方法測試看看用 Jwt 可否取得,這邊用 sean 登入,然後把 jwt 帶入去進行請求,可以故意把 Jwt 填錯看看,上面有特別包一個 try catch 來捕獲 JwtException 會回 403。

!https://ithelp.ithome.com.tw/upload/images/20240913/201509778ZlqzHGsY0.png

!https://ithelp.ithome.com.tw/upload/images/20240913/201509778ZlqzHGsY0.png

如果正確可以成功獲得資料

!https://ithelp.ithome.com.tw/upload/images/20240913/20150977KtSpIJXCar.png

!https://ithelp.ithome.com.tw/upload/images/20240913/20150977KtSpIJXCar.png

Ref:

相關文章也會同步更新我的部落格,有興趣也可以在裡面找其他的技術分享跟資訊。


上一篇
Day 24 - Spring Security (3) - JWT 介紹及導入
下一篇
Day 26 - 電商 RESTFul API + Spring Security (1) 商品功能
系列文
關於我和 Spring Boot 變成家人的那件事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言