ผ่อนคลายการยืนยันตัวตนเฉพาะใน dev ด้วย Spring Profile — Filter ขั้นต่ำที่ไม่กระทบโครงสร้าง prod
สิ่งที่คุณจะได้เรียนรู้
- วิธีขั้นต่ำในการรัน API “เสมือนผ่านการ authenticate แล้ว” ก่อนเชื่อมต่อ JWT verification จริง
- pattern ที่ใช้
@Profile("dev")+ObjectProviderของ Spring เพื่อ inject filter โดยไม่ทำสกปรกฝั่ง prod - จะทิ้งอะไรไว้เพื่อให้การย้ายไปใช้ Resource Server ของ prod เป็นการ “ลบเฉย ๆ”
กลุ่มเป้าหมาย
- ผู้ที่สร้าง PoC บน Spring Boot ที่ต้องการรักษาโครงสร้าง auth ไว้เมื่อขึ้น production
- นักพัฒนาที่ต้องการรู้วิธี insert filter เฉพาะ dev เข้าใน
SecurityFilterChain - คนที่ต้องการให้ “if เฉพาะ dev” อยู่ใน Configuration ไม่ใช่ Service
สภาพแวดล้อม
| รายการ | เวอร์ชัน |
|---|---|
| Java | 21 |
| Spring Boot | 3.4.5 |
| Spring Security | 6.x |
บทความในชุด (อยู่ระหว่างการพัฒนา) — บทความนี้เป็นส่วนหนึ่งของ สร้าง E-Scooter Sharing ระดับท้องถนนตั้งแต่ศูนย์เพื่อทำความเข้าใจ — ชุดบันทึก Design, Implementation, และ Operations โปรเจกต์ยังดำเนินอยู่และมีการเพิ่มบทความและบันทึก design ใหม่อย่างต่อเนื่อง แรงจูงใจของชุด, technology stack, อภิธานศัพท์ screen ID, และดัชนีบทความถูกรวบรวมไว้ที่ลิงก์ด้านบน
บทนำ
ตอนพยายามให้แอป e-scooter sharing วิ่ง E2E ทุก API หลัง login return 401

ดูใน implementation:
AuthService.verifyOtpสำเร็จเมื่อ OTP เป็น123456และคืน stringdummy-jwt-token-for-userid-{UUID}- แต่
SecurityConfigไม่มี filter ที่ตีความ string นั้น /users/me /payments/** /ekyc/** /rentals/**เลยโดน reject ด้วย.anyRequest().authenticated()กลายเป็น 401 ทั้งหมด
ตอนขึ้น production จะใช้ OAuth2 Resource Server ของ Spring Security ตรวจ signature ของ JWT จริง แต่ทำขนาดนั้นใน PoC ก็เปลืองเวลา
กลยุทธ์ที่เลือก: เพิ่ม filter เฉพาะ dev profile เพียง 1 ตัว
เราตัดอะไรทิ้ง
JWT verification จริงต้องมี:
- ตรวจ signature (HS256 / RS256 ฯลฯ)
- เช็ค expiration (
expclaim) - เช็ค issuer (
issclaim) - ดึง scope / role
ใน PoC ไม่ต้องการเลย สิ่งที่สำคัญคือ User ID ถูกดึงออกมาแล้วใส่ใน SecurityContext เป็น Authentication — ตรงนั้น business logic จะเริ่มหมุน
เราจำกัดการตัดทิ้งไว้แค่จุดนี้
Implementation ขั้นต่ำ
OncePerRequestFilter ติด @Profile("dev")
@Component
@Profile("dev")
@Slf4j
public class DevDummyJwtAuthFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private static final Pattern DUMMY_TOKEN_PATTERN =
Pattern.compile("^dummy-jwt-token-for-userid-([0-9a-fA-F\\-]{36})$");
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
String token = authHeader.substring(BEARER_PREFIX.length()).trim();
Matcher matcher = DUMMY_TOKEN_PATTERN.matcher(token);
if (matcher.matches()) {
String userId = matcher.group(1);
Authentication auth = new UsernamePasswordAuthenticationToken(
userId, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
จุดสำคัญ:
- regex มัดรูปแบบ token แน่นหนา (ไม่มี string ใด ๆ ที่สุ่มจะ authenticate ได้)
principalถูกตั้งเป็น UUID string ทำให้CurrentUserProviderอ่านกลับผ่านauthentication.getName()ได้- authorities list ว่าง (
Collections.emptyList()) dev ไม่ต้องแยก role
การ Wire เข้า SecurityConfig
ตรงนี้คือจุดที่คิดเยอะที่สุด
ถ้าใช้ @Autowired private DevDummyJwtAuthFilter devFilter; แบบตรง ๆ ตอน startup ของ prod จะ fail (“bean does not exist”) @Autowired(required = false) ก็ใช้ได้ แต่เพื่อชัดเจนเรื่อง “bean ที่อาจมีหรือไม่มี” เราใช้ ObjectProvider
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final ObjectProvider<DevDummyJwtAuthFilter> devDummyJwtAuthFilterProvider;
public SecurityConfig(ObjectProvider<DevDummyJwtAuthFilter> devDummyJwtAuthFilterProvider) {
this.devDummyJwtAuthFilterProvider = devDummyJwtAuthFilterProvider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/ports/**").permitAll()
.requestMatchers("/api/v1/scooters/*/status").permitAll()
.requestMatchers("/api/v1/auth/send-otp", "/api/v1/auth/verify-otp").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
);
// Insert dummy JWT filter เฉพาะเมื่อ dev profile active
// ใน prod bean ไม่มี ไม่เพิ่มอะไรเลย
DevDummyJwtAuthFilter devFilter = devDummyJwtAuthFilterProvider.getIfAvailable();
if (devFilter != null) {
http.addFilterBefore(devFilter, UsernamePasswordAuthenticationFilter.class);
}
return http.build();
}
}
จุดเด่น:
- การเลือก dev/prod ย่อเหลือแค่ “bean มีอยู่หรือไม่” ไม่ต้องเช็ค
environment.acceptsProfiles(...)แบบเปิดเผย - ใน prod
DevDummyJwtAuthFilterไม่ถูกสร้าง (เพราะ@Profile("dev"))getIfAvailable()เลย returnnulladdFilterBeforeไม่ถูกเรียก - ส่วนที่เหลือของ chain (permitAll,
anyRequest().authenticated()) ใช้ร่วมกันระหว่าง dev/prod
เชื่อมต่อกับ CurrentUserProvider
business logic ดึง UUID จาก Authentication ผ่าน CurrentUserProvider
@Component
public class CurrentUserProvider {
public UUID requireUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required");
}
String principalName = authentication.getName();
if (principalName == null || principalName.isBlank() || "anonymousUser".equals(principalName)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated principal");
}
try {
return UUID.fromString(principalName);
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated principal is not a valid UUID", ex);
}
}
}
ทั้ง dev และ prod ป้อน Authentication แบบเดียวกันเข้า requireUserId:
- dev:
DevDummyJwtAuthFilterดึง UUID จาก dummy JWT และตั้งให้ - prod: OAuth2 Resource Server ดึง UUID จาก JWT ที่ผ่าน signature verification และตั้งให้
CurrentUserProvider ทำงานเหมือนกันทั้งสอง business logic ไม่ต้องรู้ความต่างเลย

customer_id: cus_mock_... และ client_secret: seti_mock_... ใน screenshot คือค่าที่ Service ฝั่ง dev สร้างให้ เป็นหลักฐานว่า dummy JWT ใน Authorization header ถูก filter ตีความ ดึง UserId ออก แล้ว Service ถูกเรียก
กับดัก: JSR-305 @NonNull
ตอน override doFilterInternal parent class ของ Spring 6 ต้องการ @NonNull บน parameter
Missing non-null annotation: inherited method from OncePerRequestFilter
specifies this parameter as @NonNull
เป็นแค่ warning — build ผ่าน แต่ถ้ารู้สึกขัด ให้ import org.springframework.lang.NonNull แล้วใส่ไว้บน parameter
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain chain) throws ServletException, IOException {
...
}
หมายเหตุ: ถ้า cache ของ IDE เก่า import @NonNull ที่เพิ่งใส่อาจถูก flag เป็น “unused” ชั่วคราว ไม่ต้องสนใจตราบใดที่ compiler ผ่าน
ลบอะไรได้บ้างเมื่อขึ้น prod
หลัง PoC ใช้งานได้ การ productionization จะหดเหลือ:
- ลบ
DevDummyJwtAuthFilter.java(หรือทิ้งไว้พร้อม@Profile("dev")— prod จะไม่ instantiate อยู่แล้ว) - เพิ่ม
oauth2ResourceServer(...).jwt(...)ในSecurityConfigเพื่อตรวจ JWT จริง - สลับ
AuthServiceImpl.verifyOtpให้ออก JWT ที่เซ็น signature จริง ผ่าน jjwt หรือคล้าย
CurrentUserProvider controller service ไม่ต้องแตะ นี่คือผลตอบแทนจาก การจำกัด dev profile branch ไว้ที่ชั้น Filter / Configuration
บทเรียน
- วาง if branch dev/prod เหนือชั้น Service (ที่ Filter / Configuration) เพื่อให้ business logic ใช้ร่วมกันได้
- การจับคู่
@Profile("dev")+ObjectProviderไม่สร้าง bean ส่วนเกินใน prod และให้ Configuration จัดการกรณีไม่มี bean ด้วย null check บรรทัดเดียว - ตัดสินใจล่วงหน้าว่า อะไรจะถูกลบเมื่อขึ้น prod การย้ายจะกลายเป็นกลไก
การ authenticate ของ PoC คือ buffer ที่สร้างเส้นแบ่ง “จากนี้ไปใช้ interface เดียวกัน” ก่อนถึงเส้นแบ่งความไว้ใจจริง การมองแบบนี้คือการเดินที่เป็นไปได้จริง