Spring Security JWT ด้วย Filter

Wadcharapong Naklam
4 min readJul 11, 2024

--

วันนี้ผมจะมาเล่าวิธีการทำงานของ filter ใน Spring security เราจะเริ่มจาก use case ที่ใช้บ่อยคือ Baerer Token โดยปกติแล้วเวลาที่เราสร้าง API ขึ้นมาแล้ว เราจะแบ่ง API ออกเป็น 2 กลุ่มโดย

  • กลุ่มที่เป็นสาธารณะ หรือ public API เป็นกลุ่มที่เราจะสามารถให้ใครเรียกใช้งานก็ได้
  • กลุ่มที่เป็นส่วนตัว หรือ private API เป็นกลุ่มที่เราจะอนุญาตให้เฉพาะผู้ที่มีบัตรผ่านเท่านั้น

วันนี้เราจะสนใจไปที่กลุ่ม private API โดยจะใช้ Filter เป็นตัวจัดการของสิ่งนี้

Photo by Luther.M.E. Bottrill on Unsplash

Filter คืออะไร

ตัวกรองใช่ครับแปลตรง ๆ แบบนี้เลย เพราะฉะนั้นอะไรก็แล้วแต่ที่ไม่สามารถผ่านตัวกรองได้มันก็จะไปต่อไม่ได้ และเราสามารถกำหนดเงือนไขต่าง ๆ ของการกรองได้อีกด้วย

เราจะใช้ความสามารถนี้ในการจัดการระบุตัวตนของ request เพิ่อที่เราจะได้รู้ว่าเราจะอนุญาตให้เจ้าของ request นั้นผ่านเข้าไปใช้งาน private API ของเราหรือเปล่า

เริ่มต้นสร้างโปรเจค Spring boot

ไปที่ https://start.spring.io/ เพื่อ setup project

เราจะใช้

  • spring boot 3.3
  • java 21
  • spring web
  • spring security
  • lombok สำหรับจัดการ model

เมื่อเราลอง start project แล้วเข้าไปที่ http://localhost:8080/login จะเจอกับหน้าจอ login ที่เป็นหน้า default ของ spring security แต่เราจะข้ามสิ่งนี้ไปก่อนเพราะเราจะสนใจตัว API

จากนั้นเราจะทำการสร้าง API ขึ้นมา 2 ตัวโดยให้ 1 ตัวเป็น public และให้อีกตัวเป็น private

package com.example.demofilter;

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

@RestController
@RequestMapping("/public")
public class PublicController {

@GetMapping
public String hello() {
return "Hello World";
}
}

PublicController.java

package com.example.demofilter;

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

@RestController
@RequestMapping("/private")
public class PrivateController {

@GetMapping
public String hello() {
return "Hello World private !!!!!";
}
}

PrivateController.java

ตอนนี้เราจะมี API 2 ตัวแล้วแต่ยังไม่สามารถเข้าใช้งานเนื่องจาก Spring security ยังครอบ API enpoint ทุกเส้นอยู่เพราะฉะนั้นเราจะ request ไปยังไงก็จะเจอหน้า login เสมอ

ถัดไปเราต้องจัดการกับ Security เพื่อที่จะกำหนด API endpoint ที่เราจะให้เป็น public หรือ private โดยสร้าง SecurityConfig.java

package com.example.demofilter;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain1(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((requests) -> requests
.requestMatchers(
"/public/**"
)
.permitAll()
.anyRequest()
.authenticated()
)
;
return http.build();
}
}

เนื้อหาใน code

  • requestMatchers -> จะเป็นการจับ endpoint ไหนที่เราต้องการ คือ “/public/**” (คือ endpoint ที่ขึ้นต้นด้วย /public/ ต่อด้วยอะไรก็ได้ทั้งหมดเช่น /public/test, /public/1 etc)
  • permitAll() -> เป็นอนุญาตให้ผ่าน
  • anyRequest() -> request อื่น ๆ นอกจากที่ matchers จับไว้
  • authenticated() -> บังคับให้ผ่านการระบุตัวตนก่อน

เมื่อเราลอง run ใหม่จะพบว่า endpoint public จะสามารถใช้งานได้แล้วด้วยคำสั่ง

curl 'localhost:8080/public'

เราจะได้ response กลับมาเป็น “Hello World”

ส่วน endpoint private จะได้ response เป็น http status code 403

curl 'localhost:8080/private'

เท่านี้เป็นอันว่าเราจะมี public และ private API ไว้ใช้งานแล้ว ถัดไปเราจะทำการสร้าง Token เพราะว่าเนื้อหานี้เราจะทำ filter ของ Baerer Token กัน

เราจะเพิ่ม dependency ที่ build.gradle และสร้าง JwtService.java

 compileOnly 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
package com.example.demofilter;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import java.security.Key;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Collection;
import java.util.Date;

@Service
@AllArgsConstructor
public class JwtService {

// Define a fixed secret key (ensure this is stored securely in real applications)
private static final String SECRET_KEY = "my-fixed-secret-key-which-should-be-very-secure-and-long-enough";

private static Key getKey() {
byte[] decodedKey = Base64.getEncoder().encode(SECRET_KEY.getBytes());
return new SecretKeySpec(decodedKey, 0, decodedKey.length, "HmacSHA256");
}

public String createJwt(long userId, String username, Collection<String> rolesName, long minuteTimeOut) {
Key key = getKey();
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.claim("I_USER", userId)
.claim("U_NAME", username)
.claim("U_ROLES", rolesName)
.claim("TIMESTAMP", new Date())
.setIssuer("ABC")
.setIssuedAt(new Date())
.setExpiration(Date.from(LocalDateTime.now().plusMinutes(minuteTimeOut).atZone(ZoneId.systemDefault()).toInstant()))
.signWith(key)
.compact();
}

public Claims getClaims(@NonNull String token) {
Key key = getKey();
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}

ใน JwtService.java จะมี function createJwt เพื่อใช้สร้าง token และ function getClaims สำหรับแกะ Token

ถัดไปเราจะไปสร้าง public API สำหรับขอ Token กันที่ PublicController

package com.example.demofilter;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/public")
@RequiredArgsConstructor
public class PublicController {

final JwtService jwtService;

@GetMapping
public String hello() {
return "Hello World";
}

@GetMapping("/token")
public String getToken() {
return jwtService.createJwt(1, "userFromToken", List.of("user"), 60);
}
}

เพิ่ม endpoint -> /public/token

เมื่อเราลอง run ขึ้นมาใหม่และ request ไปที่ endpoint -> /public/token เราจะได้ token กลับมาซึ่งเป็น string ยาว ๆ ที่อ่านไม่รู้เรื่องนั้นเอง

curl  'localhost:8080/public/token' 

response -> eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJJX1VTRVIiOjEsIlVfTkFNRSI6InVzZXJGcm9tVG9rZW4iLCJVX1JPTEVTIjpbInVzZXIiXSwiVElNRVNUQU1QIjoxNzIwNjk5NzM1NDQxLCJpc3MiOiJBQkMiLCJpYXQiOjE3MjA2OTk3MzUsImV4cCI6MTcyMDcwMzMzNX0.o3mWyKDitt9oGdirKTR0PoFak8qibM4YAQjF-xI7SVk

ตอนนี้เรามี token เพื่อใช้ระบุตัวตนได้แล้ว ถัดไปเราจะไปทำ Filter ของเราเองเพื่อมากรอง request ที่ token ของเรากันเพื่อที่จะอนุญาตให้ใช้ private API ของเรานั้นเองโดยสร้าง JwtAuthorizationFilter.java


package com.example.demofilter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

public class JwtAuthorizationFilter extends OncePerRequestFilter {

final JwtService jwtService;

public JwtAuthorizationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}

@Override
public void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
String token = req.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
chain.doFilter(req, res);
return;
}
Claims claims = jwtService.getClaims(token);
Integer userId = (Integer) claims.get("I_USER");
String username = (String) claims.get("U_NAME");
List<String> roles = (List<String>) claims.get("U_ROLES");
List<SimpleGrantedAuthority> simpleGrantedAuthorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.toList();

UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, userId, simpleGrantedAuthorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(req, res);
}
}

เนื้อหาใน JwtAuthorizationFilter นี้เราจะ extends OncePerRequestFilter ซึ่ง Spring security ได้ provide ให้เราใช้ง่าย ๆ แต่เราจะต้อง implement function doFilterInternal เอง

เนื้อหาใน function doFilterInternal คือ เราจะทำการอ่าน Authorization จากส่วนของ Header Request เพื่อที่จะเอา Bearer Token ในนั้นมาตรวจสอบ ถ้าไม่เจอเราก็จะส่งต่อไปยัง Filter ถัดไป ถ้าเจอ Token ก็จะทำการแกะ Token นั้นจาก function jwtService.getClaims ที่เราทำไว้ก่อนหน้านี้นั้นเองเพื่อที่จะแกะข้อมูลที่อยู่ในนั้นมาทำการระบุตัวตน

Claims claims = jwtService.getClaims(token);
Integer userId = (Integer) claims.get("I_USER");
String username = (String) claims.get("U_NAME");
List<String> roles = (List<String>) claims.get("U_ROLES");
List<SimpleGrantedAuthority> simpleGrantedAuthorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.toList();

พอเราได้ข้อมูลมาแล้วเราจะทำการ setAuthentication ให้ SpringContext เพื่อที่เมื่อเข้าไปถึง Controller จะสามารถใช้ข้อมูลของตัวตนนี้ได้ และส่งต่อให้ Filter ถัดไป

UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, userId, simpleGrantedAuthorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(req, res);

เสร็จแล้วเราจะไปทำการเพิ่ม filter ที่ security ที่ไฟล์ SecurityConfig.java ดังนี้

package com.example.demofilter;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableMethodSecurity(
securedEnabled = true
)
@Configuration
public class SecurityConfig {

final JwtService jwtService;

public SecurityConfig(JwtService jwtService) {
this.jwtService = jwtService;
}

@Bean
public SecurityFilterChain filterChain1(HttpSecurity http) throws Exception {
JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter(jwtService);
http
.authorizeHttpRequests((requests) -> requests
.requestMatchers(
"/public/**"
)
.permitAll()
.anyRequest()
.authenticated()
)
.addFilterAfter(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class)
;
return http.build();
}
}

ใน code เราจะ inject JwtService เข้าเพื่อใช้กับ JwtAuthorizationFilter ที่เราสร้างไว้ จากนั้นเราก็จะเพิ่ม Filter ไว้หลังจาก UsernamePasswordAuthenticationFilter ทำงาน

  • addFilterAfter(jwtAuthorizationFilter, anotherFilter) -> เป็นการเพิ่ม jwtAuthorizationFilter ให้ทำงานหลังจากที่ anotherFilter ทำงานเสร็จ

ให้เราลองทำการ run ใหม่จากนั้นให้ใช้

curl  'localhost:8080/public/token'  
เพื่อขอ Token
curl 'localhost:8080/private' \
--header 'Authorization: Bearer {youToken}' \
จะได้ response -> Hello World private !!!!!

เพียงเท่านี้เราก็จะได้ Spring boot โปรเจคที่พร้อมใช้งาน jwt token แล้วครับ ส่วน Filter ก็ยังสามารถ customize ได้ตามที่เราต้องการได้อีก หรือจะเพิ่มเป็น 2 3 4 อันได้อีกด้วยตาม use case ที่เราจะใช้งาน

ผมหวังว่าบทความนี้จะช่วยให้เข้าใจการใช้งาน Filter มากขึ้นนะครับ 😄

Example Repo : https://github.com/wadcharapong-n/demofilter

--

--