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

解析 Token 取出 Claims

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@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 部分直接帶入給我們前面的方法

1
2
3
4
5
6
7
8
9
@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 就可以看到回傳結果正確。

1
!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 才會動作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@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。

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

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

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

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

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


參考資料: