ใครเลือก 401 แทน 403 เพราะกฎที่เราเขียนไม่ได้เลือก
เปิด SecConfig ของบทที่ 1 อ่านอีกรอบ แล้วหาบรรทัดที่พูดถึงรหัสตอบกลับ
http.authorizeHttpRequests { it.requestMatchers("/open", "/boom").permitAll() it.anyRequest().hasRole("ADMIN")}ไม่มีเลย · กฎบอกแค่ว่าใครผ่านได้ ไม่ได้บอกว่าคนที่ไม่ผ่านจะได้ตัวเลขอะไรกลับไป · แต่ปลายทางเดียวกันคืนสองรหัส
| ผู้เรียก | รหัสที่ได้ |
|---|---|
| ไม่ส่งรหัสผ่านเลย | 401 |
| ส่งรหัสผ่านผิด | 401 |
ส่งรหัสผ่านถูก แต่ role ไม่ใช่ ADMIN | 403 |
สมมติฐาน
หัวข้อที่มีชื่อว่า “สมมติฐาน”ต้องมีใครสักตัวถามคำถามที่กฎของเราไม่ได้ถาม
สองแถวบนกับแถวล่างต่างกันที่จุดเดียว คือผู้เรียกยืนยันตัวสำเร็จหรือยัง · สมมติฐานคือมีของบางอย่างถามคำถามนั้น แล้วเลือกรหัสจากคำตอบ
ExceptionTranslationFilterExceptionTranslationFilterfilter ที่ดักข้อผิดพลาดของ security แล้วแปลงเป็นคำตอบ HTTP · มันอยู่ใน filter chain ซึ่งอยู่ก่อน `DispatcherServlet` · `@RestControllerAdvice` จึงไม่มีทางเห็นสิ่งที่มันตอบ คือที่ที่คำถามนั้นถูกถาม · มันรับ exception จาก AuthorizationFilterAuthorizationFilterfilter ที่ตัดสินว่าคำขอผ่านกฎหรือไม่ · มาแทน `FilterSecurityInterceptor` ของรุ่นก่อน · มันเป็นตัวโยน exception ที่ `ExceptionTranslationFilter` รับต่อ แล้วเลือกทางออกสองทาง
- ผู้เรียกยังไม่ยืนยันตัว · ส่งไปที่ตัวที่ขอให้ยืนยันตัว ซึ่งตอบ 401 Unauthorized401 Unauthorizedรหัสที่แปลว่าเรายังไม่รู้ว่าคุณเป็นใคร · ไม่ได้แปลว่าคุณไม่มีสิทธิ์ · วัดแล้วว่าคำขอที่ไม่ส่งรหัสผ่าน กับคำขอที่ส่งรหัสผ่านผิด ได้รหัสนี้เท่ากัน
- ผู้เรียกยืนยันตัวแล้ว · ส่งไปที่ตัวที่บอกว่าไม่มีสิทธิ์ ซึ่งตอบ 403 Forbidden403 Forbiddenรหัสที่แปลว่าเรารู้แล้วว่าคุณเป็นใคร และคุณเรียกสิ่งนี้ไม่ได้ · การส่งรหัสผ่านมาใหม่ไม่ช่วยอะไร
ตัวที่ตอบคำถามนี้คือ AuthenticationTrustResolverAuthenticationTrustResolverตัวที่ตอบว่าผู้เรียกคนนี้เป็น anonymous หรือไม่ · **มันคือคนที่เลือกระหว่าง 401 กับ 403 ไม่ใช่กฎที่เราเขียน** · วัดแล้วโดยสลับมันตัวเดียว แล้วคำขอเดิมพลิกจาก 401 เป็น 403 ขณะที่ช่องอื่นนิ่งหมด
วิธีพิสูจน์ · สลับตัวเดียว แล้วดูว่าอะไรขยับ
หัวข้อที่มีชื่อว่า “วิธีพิสูจน์ · สลับตัวเดียว แล้วดูว่าอะไรขยับ”ถ้าสมมติฐานถูก การโกหกมันจะทำให้รหัสพลิก โดยไม่ต้องแตะกฎสักข้อ
เราเขียน resolver ที่ตอบว่า ไม่มีใครเป็น anonymous ทั้งที่ความจริงมี
object : AuthenticationTrustResolver { override fun isAnonymous(authentication: Authentication?): Boolean = false override fun isRememberMe(authentication: Authentication?): Boolean = false}ไม่แตะกฎ ไม่แตะ controller ไม่แตะรายชื่อผู้ใช้ · ถ้ารหัสของคำขอเดิมเปลี่ยน แปลว่าตัวนี้คือคนเลือก
ทางแรก · ประกาศเป็น bean แล้วไม่มีอะไรเกิดขึ้น
หัวข้อที่มีชื่อว่า “ทางแรก · ประกาศเป็น bean แล้วไม่มีอะไรเกิดขึ้น”คอมไพล์ผ่าน ไม่มีคำเตือน และไม่มีผลสักช่อง
@Configurationclass TrustConfig { @Bean fun trustResolver(): AuthenticationTrustResolver = object : AuthenticationTrustResolver { override fun isAnonymous(authentication: Authentication?): Boolean = false override fun isRememberMe(authentication: Authentication?): Boolean = false }}| ช่อง | ไม่มี resolver | ประกาศเป็น bean |
|---|---|---|
GET /admin ไม่ส่งรหัสผ่าน | 401 | 401 |
GET /admin รหัสผ่านผิด | 401 | 401 |
GET /admin สิทธิ์ไม่พอ | 403 | 403 |
ทุกช่องเท่าเดิม · bean ที่ถูกเพิกเฉยthe bean nobody readsการประกาศ `AuthenticationTrustResolver` เป็น `@Bean` · คอมไพล์ผ่าน ไม่มีคำเตือน และวัดแล้วว่าไม่มีผลสักช่อง · ต้องยัดผ่าน `ObjectPostProcessor` เท่านั้นจึงมีผล · สองทางนี้แยกกันไม่ออกตอนคอมไพล์ คือชื่อที่คอร์สนี้ใช้เรียกของแบบนี้
shapes.py วัดว่าทรงนี้คอมไพล์ผ่าน และทรงถัดไปก็คอมไพล์ผ่านเหมือนกัน
· ผลของ compiler แยกสองทางนี้ไม่ออกเลย
ถ้าคอร์สนี้หยุดที่การคอมไพล์ผ่าน บทนี้จะสอนวิธีที่ไม่ทำงาน
ทางที่สอง · ยัดลงไปในตัว filter ตรงๆ
หัวข้อที่มีชื่อว่า “ทางที่สอง · ยัดลงไปในตัว filter ตรงๆ”ไม่ฝากความหวังไว้กับการที่ใครจะหยิบ bean ไปใช้
http.exceptionHandling { it.withObjectPostProcessor(object : ObjectPostProcessor<ExceptionTranslationFilter> { override fun <O : ExceptionTranslationFilter> postProcess(f: O): O { f.setAuthenticationTrustResolver(NeverAnonymous()) return f } })}ObjectPostProcessor ตัวนี้คือตัวที่บทที่ 2 บอกว่าย้าย package · import ต้องเป็น
org.springframework.security.config.ObjectPostProcessor
ผลการวัด · และช่องที่ไม่ขยับคือหลักฐานที่หนักกว่า
หัวข้อที่มีชื่อว่า “ผลการวัด · และช่องที่ไม่ขยับคือหลักฐานที่หนักกว่า”ถ้ามีช่องอื่นขยับด้วย แปลว่า resolver ทำอย่างอื่นนอกจากที่เราอ้าง
| ช่อง | เดิม | หลังยัด resolver |
|---|---|---|
GET /boom ที่โยน exception | 500 | 500 |
GET /open ที่ permitAll ไว้ | 200 | 200 |
GET /admin ไม่ส่งรหัสผ่าน | 401 | 403 |
GET /admin รหัสผ่านผิด | 401 | 403 |
GET /admin สิทธิ์ไม่พอ | 403 | 403 |
สองช่องที่พลิกคือสองช่องที่ผู้เรียกเป็น anonymous authenticationanonymous authenticationสถานะของผู้เรียกที่ยังไม่ได้ยืนยันตัว ซึ่งระบบถือว่าเป็นผู้ใช้ชนิดหนึ่ง ไม่ใช่การไม่มีผู้ใช้ · **รหัสผ่านที่ผิดก็นับเป็นสถานะนี้** ซึ่งเป็นข้อที่เดาไม่ถูกจากรหัสตอบกลับ พอดี · ช่องที่เหลือนิ่งทั้งสามช่อง
เราไม่ได้แก้กฎสักบรรทัด ไม่ได้แก้ controller สักตัวอักษร และไม่ได้แตะรายชื่อผู้ใช้ · สิ่งเดียวที่เปลี่ยนคือคำตอบของคำถาม “ผู้เรียกคนนี้เป็น anonymous ไหม”
รหัส 401 กับ 403 เป็นผลของคำตอบนั้น ไม่ใช่ผลของกฎที่เราเขียน
ของแถมที่เดาไม่ถูกจากรหัส
หัวข้อที่มีชื่อว่า “ของแถมที่เดาไม่ถูกจากรหัส”รหัสผ่านที่ผิด ถูกนับเป็นผู้เรียกที่ยังไม่ยืนยันตัว
แถว “รหัสผ่านผิด” พลิกพร้อมกับแถว “ไม่ส่งรหัสผ่านเลย” · สองอย่างนี้ต่างกันมากในสายตาคน แต่ระบบมองเหมือนกัน คือยังไม่มีใครยืนยันตัวสำเร็จ
ผลคือผู้เรียกที่พิมพ์รหัสผ่านผิด ได้รหัสเดียวกับผู้เรียกที่ไม่เคยส่งอะไรมาเลย · ถ้าคุณเคยเขียน client แล้วงงว่าทำไมแยกสองกรณีนี้ไม่ออก นี่คือเหตุผล
resolver ที่ตอบว่าไม่มีใครเป็น anonymous มีไว้เพื่อพิสูจน์กลไกเท่านั้น
· ใช้จริงแล้วผู้เรียกที่ยังไม่ยืนยันตัวจะได้ 403 ซึ่งบอกเขาว่าอย่าลองส่งรหัสผ่านมา
ทั้งที่การส่งรหัสผ่านคือสิ่งที่เขาควรทำ
สิ่งที่บทนี้ให้คือความรู้ว่าใครเป็นคนเลือก ไม่ใช่คำแนะนำให้ไปเปลี่ยนมัน
สิ่งที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “สิ่งที่บทนี้ทิ้งไว้”รู้แล้วว่าใครเลือกรหัส ยังไม่รู้ว่ากฎที่เขียนไว้ถูกใช้จริงทุกข้อไหม
บทนี้มี SecurityFilterChain ตัวเดียว ทุกกฎจึงอยู่ในที่เดียวกัน
· บทถัดไปเพิ่มตัวที่สอง แล้ววัดว่ากฎที่อยู่ในตัวหลังได้ทำงานหรือเปล่า
- Spring Boot 4.1.1 · Spring Security 7.1.1 · Kotlin 2.4.10 · JDK 25
- รันซ้ำเองได้ด้วย
npm run verify:clinic-securityซึ่งสร้างโปรเจกต์ขึ้นใหม่สิบสองทรง แล้วเทียบผลทีละช่อง · ต้องสั่ง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 - Spring Security — Anonymous
(เข้าถึง 2026-09-02) — บทบาทของ
AuthenticationTrustResolver
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3ทำไมช่องที่ **ไม่** ขยับถึงเป็นหลักฐานที่หนักกว่าช่องที่ขยับ