Tech Blog

ผ่อนคลายการยืนยันตัวตนเฉพาะใน dev ด้วย Spring Profile — Filter ขั้นต่ำที่ไม่กระทบโครงสร้าง prod

by Tech Writer
Spring Security Spring Boot Java Profile JWT PoC Design

สิ่งที่คุณจะได้เรียนรู้

  • วิธีขั้นต่ำในการรัน 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

สภาพแวดล้อม

รายการเวอร์ชัน
Java21
Spring Boot3.4.5
Spring Security6.x

บทความในชุด (อยู่ระหว่างการพัฒนา) — บทความนี้เป็นส่วนหนึ่งของ สร้าง E-Scooter Sharing ระดับท้องถนนตั้งแต่ศูนย์เพื่อทำความเข้าใจ — ชุดบันทึก Design, Implementation, และ Operations โปรเจกต์ยังดำเนินอยู่และมีการเพิ่มบทความและบันทึก design ใหม่อย่างต่อเนื่อง แรงจูงใจของชุด, technology stack, อภิธานศัพท์ screen ID, และดัชนีบทความถูกรวบรวมไว้ที่ลิงก์ด้านบน

บทนำ

ตอนพยายามให้แอป e-scooter sharing วิ่ง E2E ทุก API หลัง login return 401

หน้า S05 login — endpoint สาธารณะตรงนี้ไม่ return 401

ดูใน implementation:

  • AuthService.verifyOtp สำเร็จเมื่อ OTP เป็น 123456 และคืน string dummy-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 (exp claim)
  • เช็ค issuer (iss claim)
  • ดึง 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() เลย return null addFilterBefore ไม่ถูกเรียก
  • ส่วนที่เหลือของ 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 ไม่ต้องรู้ความต่างเลย

S06 ลงทะเบียนการจ่ายเงิน — endpoint ที่ต้อง authenticate ผ่านได้ด้วย 200 หลังจาก filter ทำงาน

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 จะหดเหลือ:

  1. ลบ DevDummyJwtAuthFilter.java (หรือทิ้งไว้พร้อม @Profile("dev") — prod จะไม่ instantiate อยู่แล้ว)
  2. เพิ่ม oauth2ResourceServer(...).jwt(...) ใน SecurityConfig เพื่อตรวจ JWT จริง
  3. สลับ 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 เดียวกัน” ก่อนถึงเส้นแบ่งความไว้ใจจริง การมองแบบนี้คือการเดินที่เป็นไปได้จริง

ส่งข้อความได้ตามสบาย

กรุณาส่งข้อความ หากมีคำปรึกษาด้านเทคนิค ความคิดเห็น หรือคำถาม