หนึ่งบรรทัดที่ปิดทั้งแอป แล้วสัญญาเดิมก็หายไป
คอร์สก่อนหน้าจบด้วยสัญญาสามข้อ ที่เขียนเป็นตาราง แล้วบังคับด้วยเทสต์
- คำขอที่ผิดได้
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 ไม่เพิ่มกฎสักข้อ
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 ของสองทรงก่อนวัดทุกครั้ง แล้วยืนยันว่าต่างกันหนึ่งบรรทัดพอดี · ถ้าต่างมากกว่านั้น มันหยุดแล้วรายงานว่าวัดไม่ได้ แทนที่จะรายงานตัวเลข
เพิ่มหนึ่งบรรทัด แล้วทุกช่องกลายเป็น 401
หัวข้อที่มีชื่อว่า “เพิ่มหนึ่งบรรทัด แล้วทุกช่องกลายเป็น 401”ทุกช่องขอ
application/jsonและไม่ตาม redirect
| ช่องที่ยิง | ก่อน | หลัง |
|---|---|---|
GET /boom ที่โยน exception | 500 · 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 เคยคืนข้อมูลให้ทุกคนที่ขอ
· แต่แถวแรกกับแถวที่สองบอกอีกเรื่อง คือของที่ควรเปิดก็ปิดไปด้วย
เขียนกฎขั้นต่ำ แล้ววัดใหม่
หัวข้อที่มีชื่อว่า “เขียนกฎขั้นต่ำ แล้ววัดใหม่”ปิดหมดไม่ใช่คำตอบ เพราะแอปที่ไม่มีใครเรียกได้ ก็ไม่ต่างจากแอปที่ล่ม
@Configurationclass 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 ที่โยน exception | 500 | 5 |
GET /open ที่ permitAll ไว้ | 200 | 1 |
GET /admin ไม่ส่งรหัสผ่าน | 401 | 0 |
GET /admin รหัสผ่านผิด | 401 | 0 |
GET /admin รหัสผ่านถูก สิทธิ์ไม่พอ | 403 | 0 |
body ที่ว่างเปล่า ไม่ใช่ body อีกรูปหนึ่ง
หัวข้อที่มีชื่อว่า “body ที่ว่างเปล่า ไม่ใช่ body อีกรูปหนึ่ง”สัญญาที่คอร์สก่อนสร้างขึ้น ไม่ได้ถูกแทนที่ มันหายไปเฉยๆ
ตอนออกแบบคอร์สนี้ ผมเดาไว้ว่าจะเจอ body สองรูปปนกัน คือรูปของเรากับรูปของ Spring Security · การวัดตอบว่าไม่ใช่ · สามแถวล่างได้ศูนย์คีย์เท่ากันหมด
สิ่งเดียวที่กลับไปนอกจากรหัส คือ header WWW-Authenticate ของสองช่องที่ผู้เรียกยังไม่ยืนยันตัว
· มันบอกวิธียืนยันตัว ไม่ได้บอกว่าอะไรผิด · ช่องที่สิทธิ์ไม่พอไม่ได้แม้แต่ header นั้น
คำตอบที่ผิดรูปยังบอกได้ว่ามีใครสักคนเขียนมันขึ้นมา · คำตอบที่ว่างเปล่าบอกไม่ได้ว่าเกิดอะไรขึ้น
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 ดักตรงนี้ │ └── ถูกปฏิเสธและตอบกลับตรงนี้ ไม่เดินต่อคนละเรื่องกับบทที่ 3 ของคอร์สก่อน
หัวข้อที่มีชื่อว่า “คนละเรื่องกับบทที่ 3 ของคอร์สก่อน”ทั้งสองบท 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` ถึงจำเป็นต่อข้อสรุปของบทนี้