24 - JWT Token
เป้าหมายของบท
Section titled “เป้าหมายของบท”บทนี้จะเปลี่ยน login response จาก token placeholder เป็น JWT จริง และตั้งค่า Spring Security ให้ตรวจ token จาก request ถัดไป
หลังจบบทนี้ผู้อ่านควรเข้าใจ:
- JWT คืออะไร
- token ถูกส่งผ่าน
Authorization: Bearer <token>อย่างไร - payload อ่านได้ จึงห้ามใส่ข้อมูลลับ
- ใช้
JwtEncoderเพื่อสร้าง token - ใช้ Spring Security OAuth2 Resource Server เพื่อตรวจ token
- map role จาก JWT claim เป็น Spring Security authority
เพิ่ม dependency
Section titled “เพิ่ม dependency”สำหรับ Spring Boot 4 ใช้ starter ชุดใหม่:
implementation 'org.springframework.boot:spring-boot-starter-security'implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'ถ้าใช้ Spring Boot 3 ให้ใช้ artifact เดิม:
implementation 'org.springframework.boot:spring-boot-starter-security'implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'หนังสือนี้จะใช้ Spring Boot 4 เป็นหลัก
JWT คืออะไร
Section titled “JWT คืออะไร”JWT มี 3 ส่วน:
header.payload.signatureตัวอย่าง payload:
{ "iss": "backend-api", "sub": "john@example.com", "userId": 1, "roles": ["USER"], "iat": 1781490000, "exp": 1781576400}payload สามารถ decode อ่านได้ จึงห้ามใส่ password, token ลับ, เลขบัตร หรือข้อมูล sensitive อื่น
ตั้งค่า secret
Section titled “ตั้งค่า secret”เพิ่มใน application.properties:
app.jwt.secret=${JWT_SECRET:0123456789012345678901234567890123456789012345678901234567890123}app.jwt.expiration-seconds=86400app.jwt.issuer=backend-apisecret ควรยาวพอสำหรับ HMAC SHA-256 และในงานจริงต้องเก็บเป็น environment variable ไม่ใช่ hardcode ใน source code
สร้าง JwtProperties
Section titled “สร้าง JwtProperties”สร้างไฟล์:
src/main/java/com/example/backendapi/config/JwtProperties.javapackage com.example.backendapi.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.jwt")public record JwtProperties( String secret, long expirationSeconds, String issuer) {}สร้าง SecurityConfig
Section titled “สร้าง SecurityConfig”ในบท 22 เราเคยสร้าง SecurityBeansConfig เพื่อประกาศ PasswordEncoder แยกไว้ก่อน
แต่จากบทนี้เป็นต้นไป SecurityConfig จะรวมทั้ง security rule และ PasswordEncoder ไว้ในไฟล์เดียว
ให้ลบไฟล์เดิมก่อนเพื่อไม่ให้ Spring เจอ bean ชื่อ passwordEncoder ซ้ำ:
src/main/java/com/example/backendapi/config/SecurityBeansConfig.javaถ้าไม่ลบไฟล์นี้ แอปจะ start ไม่ขึ้นด้วย error ประมาณ BeanDefinitionOverrideException
สร้างไฟล์:
src/main/java/com/example/backendapi/config/SecurityConfig.javapackage com.example.backendapi.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;import javax.crypto.SecretKey;import javax.crypto.spec.SecretKeySpec;import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;import org.springframework.security.config.http.SessionCreationPolicy;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.oauth2.jose.jws.MacAlgorithm;import org.springframework.security.oauth2.jwt.JwtDecoder;import org.springframework.security.oauth2.jwt.JwtEncoder;import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;import org.springframework.security.web.SecurityFilterChain;
@Configuration@EnableConfigurationProperties(JwtProperties.class)public class SecurityConfig {
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/register", "/api/v1/auth/login").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); }
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean public SecretKey jwtSecretKey(JwtProperties jwtProperties) { return new SecretKeySpec(jwtProperties.secret().getBytes(), "HmacSHA256"); }
@Bean public JwtEncoder jwtEncoder(SecretKey jwtSecretKey) { return new NimbusJwtEncoder(new ImmutableSecret<>(jwtSecretKey)); }
@Bean public JwtDecoder jwtDecoder(SecretKey jwtSecretKey) { return NimbusJwtDecoder.withSecretKey(jwtSecretKey) .macAlgorithm(MacAlgorithm.HS256) .build(); }}สร้าง JwtService
Section titled “สร้าง JwtService”สร้างไฟล์:
src/main/java/com/example/backendapi/service/JwtService.javapackage com.example.backendapi.service;
import com.example.backendapi.config.JwtProperties;import com.example.backendapi.model.User;import java.time.Instant;import java.util.List;import lombok.RequiredArgsConstructor;import org.springframework.security.oauth2.jose.jws.MacAlgorithm;import org.springframework.security.oauth2.jwt.JwtClaimsSet;import org.springframework.security.oauth2.jwt.JwtEncoder;import org.springframework.security.oauth2.jwt.JwtEncoderParameters;import org.springframework.security.oauth2.jwt.JwsHeader;import org.springframework.stereotype.Service;
@Service@RequiredArgsConstructorpublic class JwtService {
private final JwtEncoder jwtEncoder; private final JwtProperties jwtProperties;
public String generateToken(User user) { Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder() .issuer(jwtProperties.issuer()) .issuedAt(now) .expiresAt(now.plusSeconds(jwtProperties.expirationSeconds())) .subject(user.getEmail()) .claim("userId", user.getId()) .claim("roles", List.of(user.getRole().name())) .build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); }}ใช้ JWT ใน login
Section titled “ใช้ JWT ใน login”ปรับ AuthService.login:
String token = jwtService.generateToken(user);return new LoginResponse(token, toResponse(user));เพิ่ม field:
private final JwtService jwtService;ทดสอบ token
Section titled “ทดสอบ token”หลัง login สำเร็จ response จะมี token:
{ "success": true, "message": "Login successful", "data": { "token": "eyJ...", "user": { "id": 1, "email": "john@example.com" } }}request ถัดไปต้องส่ง:
Authorization: Bearer eyJ...ข้อควรระวัง
Section titled “ข้อควรระวัง”- JWT payload อ่านได้ ห้ามเก็บ password หรือข้อมูลลับ
- secret ต้องอยู่ใน environment variable
- token ควรมีเวลาหมดอายุ
- ถ้าต้อง revoke token ก่อนหมดอายุ ต้องมี blacklist หรือเปลี่ยน token strategy เพิ่ม
- สำหรับระบบใหญ่ควรพิจารณา refresh token แยกจาก access token
แบบฝึกหัดท้ายบท
Section titled “แบบฝึกหัดท้ายบท”- เพิ่ม dependency security/resource server
- เพิ่ม
app.jwt.*config - สร้าง
JwtProperties - ลบ
SecurityBeansConfigจากบท 22 - สร้าง
SecurityConfig - สร้าง
JwtService - ปรับ login ให้คืน JWT จริง
- นำ token ไป decode ดู payload และยืนยันว่าไม่มี password