Skip to content

24 - JWT Token

บทนี้จะเปลี่ยน 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

สำหรับ 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 มี 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 อื่น

เพิ่มใน application.properties:

app.jwt.secret=${JWT_SECRET:0123456789012345678901234567890123456789012345678901234567890123}
app.jwt.expiration-seconds=86400
app.jwt.issuer=backend-api

secret ควรยาวพอสำหรับ HMAC SHA-256 และในงานจริงต้องเก็บเป็น environment variable ไม่ใช่ hardcode ใน source code

สร้างไฟล์:

src/main/java/com/example/backendapi/config/JwtProperties.java
package com.example.backendapi.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(
String secret,
long expirationSeconds,
String issuer
) {
}

ในบท 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.java
package 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();
}
}

สร้างไฟล์:

src/main/java/com/example/backendapi/service/JwtService.java
package 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
@RequiredArgsConstructor
public 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();
}
}

ปรับ AuthService.login:

String token = jwtService.generateToken(user);
return new LoginResponse(token, toResponse(user));

เพิ่ม field:

private final JwtService jwtService;

หลัง login สำเร็จ response จะมี token:

{
"success": true,
"message": "Login successful",
"data": {
"token": "eyJ...",
"user": {
"id": 1,
"email": "john@example.com"
}
}
}

request ถัดไปต้องส่ง:

Authorization: Bearer eyJ...
  • JWT payload อ่านได้ ห้ามเก็บ password หรือข้อมูลลับ
  • secret ต้องอยู่ใน environment variable
  • token ควรมีเวลาหมดอายุ
  • ถ้าต้อง revoke token ก่อนหมดอายุ ต้องมี blacklist หรือเปลี่ยน token strategy เพิ่ม
  • สำหรับระบบใหญ่ควรพิจารณา refresh token แยกจาก access token

แบบฝึกหัดท้ายบท

Section titled “แบบฝึกหัดท้ายบท”
  1. เพิ่ม dependency security/resource server
  2. เพิ่ม app.jwt.* config
  3. สร้าง JwtProperties
  4. ลบ SecurityBeansConfig จากบท 22
  5. สร้าง SecurityConfig
  6. สร้าง JwtService
  7. ปรับ login ให้คืน JWT จริง
  8. นำ token ไป decode ดู payload และยืนยันว่าไม่มี password