ข้าม​ไป​ยัง​เนื้อหา

หนึ่ง​บรรทัด​ที่​ปิด​ทั้ง​แอป แล้ว​สัญญา​เดิม​ก็​หาย​ไป

คอร์ส​ก่อน​หน้า​จบ​ด้วย​สัญญา​สาม​ข้อ ที่​เขียน​เป็น​ตาราง แล้ว​บังคับ​ด้วย​เทสต์

  • คำขอ​ที่​ผิด​ได้ 400 พร้อม​รายการ​ช่อง​ที่​กรอก​ผิด
  • ความ​ขัด​แย้ง​ของ​ข้อมูล​ได้ 409 ไม่ใช่ 500
  • ทั้ง​สาม​กรณี​ใช้ body รูป​เดียวกัน

จาก​นั้น​เรา​เพิ่ม dependency หนึ่ง​บรรทัด

🔒 คอร์ส​นี้​ต่อ​จาก​ของ​เดิม ไม่​ได้​เริ่ม​ใหม่

โปร​เจ​กต์​เดียว​กับ คอร์ส​ขอบ HTTP ของ​คลินิก · สิ่ง​ที่​คอร์ส​นี้​เพิ่ม​คือ​คำถาม​ว่า​ใคร​เรียก​ได้ และ​เห็น​ข้อมูล​ของ​ใคร

ทั้ง​คอร์ส​ไม่มี​หน่วย​เวลา​สัก​ตัว หน่วย​ที่​ใช้​มี​ห้า​อย่าง

  • รหัส HTTP
  • ชื่อ​และ​จำนวน​คีย์​ใน body
  • ชื่อ filter และ​ชื่อ chain
  • ชื่อ​สิทธิ์
  • จำนวน​แถว

บท​ที่ 2 ไม่​ต้อง​ใช้ Docker เพราะ​มัน​วัด​แค่​ว่า config คอม​ไพล์​ผ่าน​หรือ​ไม่ · บท​อื่น​ใช้ probe ตัว​เดียวกัน ซึ่ง​ต้อง​สั่ง scripts/clinic-security/up.sh ก่อน · รัน​เอง​ได้​ด้วย python3 scripts/clinic-security/runtime.py

ไม่​แตะ config ไม่​แตะ controller ไม่​เพิ่ม​กฎ​สัก​ข้อ

app/build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}

probe เทียบ build file ของ​สอง​ทรง​ก่อน​วัด​ทุก​ครั้ง แล้ว​ยืนยัน​ว่า​ต่าง​กันหนึ่ง​บรรทัด​พอดี · ถ้า​ต่าง​มากกว่า​นั้น มัน​หยุด​แล้ว​รายงาน​ว่า​วัด​ไม่​ได้ แทนที่​จะ​รายงาน​ตัวเลข

ทุก​ช่อง​ขอ application/json และ​ไม่​ตาม redirect

ช่อง​ที่​ยิงก่อนหลัง
GET /boom ที่​โยน exception500 · 5 คีย์401 · 0 คีย์
GET /open ที่​ไม่มี​กฎ​กัน​ไว้200 · 1 คีย์401 · 0 คีย์
GET /admin ไม่​ส่ง​รหัส​ผ่าน200 · 1 คีย์401 · 0 คีย์
GET /admin รหัส​ผ่าน​ผิด200 · 1 คีย์401 · 0 คีย์
GET /admin รหัส​ผ่าน​ถูก200 · 1 คีย์401 · 0 คีย์

spring-boot-starter-securityspring-boot-starter-securitydependency หนึ่ง​บรรทัด​ที่​เปิด filter chain ของ Spring Security ให้​ทั้ง​แอป · วัด​แล้ว​ว่า​หลัง​เพิ่ม​บรรทัด​นี้ คำ​ตอบ​ที่​ปฏิเสธ​ผู้​เรียก​มี body ว่าง​เปล่า ไม่มี​คีย์​สัก​ตัว ไม่​ได้​ถาม​เรา​ว่า​จะ​ให้​ปกป้อง​อะไร มัน​ปกป้อง​ทุก​อย่าง

แถว​ที่​สาม​คือ​ปัญหา​ที่​คอร์ส​นี้​ตั้งใจ​จะ​แก้ · /admin เคย​คืน​ข้อมูล​ให้​ทุก​คน​ที่​ขอ · แต่​แถว​แรก​กับ​แถว​ที่​สอง​บอก​อีก​เรื่อง คือ​ของ​ที่ควรเปิด​ก็​ปิด​ไป​ด้วย

ปิด​หมด​ไม่ใช่​คำ​ตอบ เพราะ​แอป​ที่​ไม่มี​ใคร​เรียก​ได้ ก็​ไม่​ต่าง​จาก​แอป​ที่​ล่ม

SecConfig.kt — กฎ​ขั้น​ต่ำ​ที่ probe ใช้​วัด​ทุก​ทรง​หลัง​จาก​นี้
@Configuration
class SecConfig {
@Bean
fun chain(http: HttpSecurity): SecurityFilterChain {
http.csrf { it.disable() }
http.authorizeHttpRequests {
it.requestMatchers("/open", "/boom").permitAll()
it.anyRequest().hasRole("ADMIN")
}
http.httpBasic { }
return http.build()
}
@Bean
fun users(): UserDetailsService = InMemoryUserDetailsManager(
User.withUsername("kaen").password("{noop}pw-for-probe-only").roles("USER").build(),
)
}

ผู้​ใช้​คน​เดียว​ใน​ระบบ​ถือ role USER ส่วน​กฎ​ขอ role ADMIN · ความ​ไม่​ตรง​กัน​นี้​จงใจ เพื่อ​ให้​มี​ผู้​เรียก​ที่​ยืนยัน​ตัว​สำเร็จ​แต่​สิทธิ์​ไม่​พอ

ช่อง​ที่​ยิงรหัสจำนวน​คีย์​ใน body
GET /boom ที่​โยน exception5005
GET /open ที่ permitAll ไว้2001
GET /admin ไม่​ส่ง​รหัส​ผ่าน4010
GET /admin รหัส​ผ่าน​ผิด4010
GET /admin รหัส​ผ่าน​ถูก สิทธิ์​ไม่​พอ4030

สัญญา​ที่​คอร์ส​ก่อน​สร้าง​ขึ้น ไม่​ได้​ถูก​แทนที่ มัน​หาย​ไป​เฉยๆ

ตอน​ออกแบบ​คอร์ส​นี้ ผม​เดา​ไว้​ว่า​จะ​เจอ body สอง​รูป​ปน​กัน คือ​รูป​ของ​เรา​กับ​รูป​ของ Spring Security · การ​วัด​ตอบ​ว่า​ไม่ใช่ · สาม​แถว​ล่าง​ได้​ศูนย์​คีย์​เท่า​กัน​หมด

สิ่ง​เดียว​ที่​กลับ​ไป​นอกจาก​รหัส คือ header WWW-Authenticate ของ​สอง​ช่อง​ที่​ผู้​เรียก​ยัง​ไม่​ยืนยัน​ตัว · มัน​บอก​วิธี​ยืนยัน​ตัว ไม่​ได้​บอก​ว่า​อะไร​ผิด · ช่อง​ที่​สิทธิ์​ไม่​พอ​ไม่​ได้​แม้แต่ header นั้น

สิ่ง​ที่​แย่​กว่า​คำ​ตอบ​ที่​ผิด​รูป

คำ​ตอบ​ที่​ผิด​รูป​ยัง​บอก​ได้​ว่า​มี​ใคร​สัก​คน​เขียน​มัน​ขึ้น​มา · คำ​ตอบ​ที่​ว่าง​เปล่า​บอก​ไม่​ได้​ว่า​เกิด​อะไร​ขึ้น

advice ของ​เรา​ไม่​ได้​พัง และ​เรา​รู้​เพราะ​ช่อง​ควบคุม

หัวข้อ​ที่​มีชื่อ​ว่า “advice ของ​เรา​ไม่​ได้​พัง และ​เรา​รู้​เพราะ​ช่อง​ควบคุม”

ถ้า​ไม่มี​ช่อง​นี้ ข้อ​สรุป​ทั้ง​บท​เป็น​แค่​การ​เดา

เรา​เขียน advice ที่​ใช้​วัด​ให้​แข็ง​แรง​ที่สุด​สอง​ทาง คือ​ดัก​กว้าง​ที่สุด และ​ประกาศ​ลำดับ​สูงสุด

advice ที่​ใช้​วัด
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
class WideAdvice {
@ExceptionHandler(Exception::class)
fun onAny(e: Exception): ProblemDetail {
val pd = ProblemDetail.forStatus(500)
pd.setProperty("kaenAdvice", "KAEN-SEC35-ADVICE")
pd.setProperty("kaenSaw", e.javaClass.simpleName)
return pd
}
}

ช่อง​ควบคุม GET /boom ได้ 500 พร้อม​คีย์​ครบ​ห้า​ตัว คือ instance kaenAdvice kaenSaw status title · และ kaenSaw มี​ค่า​เป็น IllegalStateException ซึ่ง​เป็น​ชนิด​ที่​ปลาย​ทาง​นั้น​โยน​ออก​มา​จริง

advice ทำงาน​อยู่ · คำ​ตอบ​ที่​ว่าง​เปล่า​จึง​ไม่​ได้​แปล​ว่า​มัน​พัง

คำขอ​ที่​ถูก​ปฏิเสธ ไม่​เคย​เดิน​ทาง​ไป​ถึง​ชั้น​ที่ advice อยู่

AuthorizationFilterAuthorizationFilterfilter ที่​ตัดสิน​ว่า​คำขอ​ผ่าน​กฎ​หรือ​ไม่ · มา​แทน `FilterSecurityInterceptor` ของ​รุ่น​ก่อน · มัน​เป็น​ตัว​โยน exception ที่ `ExceptionTranslationFilter` รับ​ต่อ ปฏิเสธ​คำขอ​ตั้งแต่​ใน filter chain แล้ว​โยน exception ออก​มา · ExceptionTranslationFilterExceptionTranslationFilterfilter ที่​ดัก​ข้อ​ผิด​พลาด​ของ security แล้ว​แปลง​เป็น​คำ​ตอบ HTTP · มัน​อยู่​ใน filter chain ซึ่ง​อยู่​ก่อน `DispatcherServlet` · `@RestControllerAdvice` จึง​ไม่มี​ทาง​เห็น​สิ่ง​ที่​มัน​ตอบ ซึ่ง​ครอบ​มัน​อยู่ รับ exception นั้น​ไป​แปลง​เป็น​คำ​ตอบ HTTP

@RestControllerAdvice ทำงาน​อยู่​ใต้ DispatcherServlet · คำขอ​ที่​ถูก​ปฏิเสธ​จบ​เรื่อง​ก่อน​ถึงที่​นั่น จึง​ไม่มี​อะไร​ให้ advice ดัก

คำขอ ─→ filter chain ─────────────→ DispatcherServlet ─→ controller
│ │
│ └── @RestControllerAdvice ดักตรงนี้
│
└── ถูกปฏิเสธและตอบกลับตรงนี้ ไม่เดินต่อ

ทั้ง​สอง​บท advice ไม่​ได้​รัน แต่​คนละ​สาเหตุ และ​วิธี​แก้​จึง​คนละ​อัน

คอร์ส​ก่อน​มี​บท​ชื่อ ตัว​จัดการ​ที่​ไม่​เคย​รัน · อ่าน​ชื่อ​แล้ว​เหมือน​เรื่อง​เดียวกัน แต่​ไม่ใช่

บท​ที่ 3 ของ​คอร์ส​ก่อนบท​นี้
advice ได้​รัน​ไหมไม่​ได้​รันไม่​ได้​รัน
อยู่​ชั้น​เดียว​กับ​เรื่อง​ไหมอยู่ · แต่​มี​ตัว​ของ Spring จัด​ลำดับ​ไว้​ก่อนไม่​อยู่ · เรื่อง​จบ​ก่อน​ถึง​ชั้น​นั้น
แก้​ด้วยประกาศ​ลำดับ​ให้​ชนะเขียน​ตัว​จัดการ​ของ filter chain เอง

การ​วัด​รอบ​นี้​ตัด​วิธี​แก้​ของ​บท​นั้น​ออก​ไป​แล้ว · advice ที่​ใช้​วัด​ประกาศ​ลำดับ​สูงสุด ซึ่ง​เป็น​วิธี​แก้​ของ​บท​ก่อน และ​ดัก Exception ซึ่ง​กว้าง​ที่สุด · มัน​ยัง​ไม่​เห็น

@RestControllerAdvice ที่​ไม่มี​ทาง​เห็นadvice out of reachadvice ที่​ทำงาน​ปกติ​ทุก​อย่าง แต่​ไม่​เห็น​ข้อ​ผิด​พลาด​ของ security เลย · วัด​แล้ว​ด้วย advice ที่​ดัก `Exception` และ​ประกาศ​ลำดับ​สูงสุด ซึ่ง​เป็น​กรณี​สูงสุด​เท่า​ที่​เขียน​ได้ · มัน​ยัง​ไม่​เห็น เพราะ​เรื่อง​ไม่​ได้​เกิด​ใน​ระบบ​เดียวกัน ไม่ใช่​เพราะ​ดัก​แคบ​ไป​หรือ​แพ้​ลำดับ คือ​ชื่อ​ที่​คอร์ส​นี้​ใช้​เรียก​อาการ​นั้น

รู้​แล้ว​ว่า​สัญญา​พัง ยัง​ไม่รู้​ว่า​เขียน config อย่างไร​ให้​ถูก​รุ่น

SecConfig ข้าง​บน​คอม​ไพล์​ผ่าน​บน​รุ่น​ที่​เรา​ใช้ · แต่​รูป​ที่​รุ่น​ก่อน​หน้า​เคย​เขียน​กัน แปะ​ลง​รุ่น​นี้​แล้ว​คอม​ไพล์​ไม่​ผ่าน · บท​ถัด​ไป​เริ่ม​ด้วย​การ​วัด​ว่า​รูป​ไหน​ตาย​ไป​บ้าง และ compiler พูด​ว่า​อะไร

ที่มา​ของ​ตัวเลข​ใน​บท​นี้
  • Spring Boot 4.1.1 · Spring Security 7.1.1 · Kotlin 2.4.10 · JDK 25
  • รัน​ซ้ำ​เอง​ได้​ด้วย python3 scripts/clinic-security/runtime.py ซึ่ง​สร้าง​โปร​เจ​กต์​ทั้ง​ใบ​ขึ้น​ใหม่​สิบ​สอง​ทรง แล้ว​เทียบ​ผล​ที​ละ​ช่อง · ต้อง​สั่ง scripts/clinic-security/up.sh ก่อน เพราะ​ทรง​ของ​บท​ที่ 7 ต้อง​ใช้​ฐาน​ข้อมูล
  • ค่าที่​วัด​ได้​ถูก​ตรึง​ไว้​ที่ src/data/perf/clinic-security.ts และ test/course-clinic-security.test.ts ผูก​ตัวเลข​ใน​บท​นี้​เข้า​กับ file นั้น
  • Spring Security — Architecture (เข้า​ถึง 2026-09-02) — ตำแหน่ง​ของ ExceptionTranslationFilter ใน filter chain

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

ทำไมช่องควบคุม `GET /boom` ถึงจำเป็นต่อข้อสรุปของบทนี้