Spring Security JWT ด้วย Filter
วันนี้ผมจะมาเล่าวิธีการทำงานของ filter ใน Spring security เราจะเริ่มจาก use case ที่ใช้บ่อยคือ Baerer Token โดยปกติแล้วเวลาที่เราสร้าง API ขึ้นมาแล้ว เราจะแบ่ง API ออกเป็น 2 กลุ่มโดย
- กลุ่มที่เป็นสาธารณะ หรือ public API เป็นกลุ่มที่เราจะสามารถให้ใครเรียกใช้งานก็ได้
- กลุ่มที่เป็นส่วนตัว หรือ private API เป็นกลุ่มที่เราจะอนุญาตให้เฉพาะผู้ที่มีบัตรผ่านเท่านั้น
วันนี้เราจะสนใจไปที่กลุ่ม private API โดยจะใช้ Filter เป็นตัวจัดการของสิ่งนี้
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