Boopack with Spring Security part 2/5
เป้าหมายของ part นี้คือลงทะเบียนและเข้าสู่ระบบด้วย email กับ password ที่มีอยู่ในฐานข้อมูล ทว่าหน้า Register ยังไม่ได้ทำ งั้นลุย มาเลยอร่อยกัน!
โค้ดทั้งหมดบน Github
อ่าน part ก่อนหน้านี้
Angular สร้างหน้า Register
พิมพ์
ng generate component register
หรืออย่างย่อ
ng g c register
แล้วไปสร้าง route ของมัน เปิดไฟล์ app-routing.module.ts
const routes: Routes = [{ path: 'register', component: RegisterComponent },{ path: 'login', component: LoginComponent },{ path: '**', redirectTo: '' }];
ทดสอบ
แต่งเติมเลยครับ
frontend เสร็จไปแล้วอย่างรวดเร็ว งานนี้เมื่อทดสอบกดปุ่ม REGISTER ปรากฏว่ามันร้องขอ /register ไปที่ backend ซึ่งไม่มีอยู่ เหตุเพราะ Spring Security ได้ปกป้อง resources ด้วย Basic Authentication ผลคือ resources ใดๆที่ถูกร้องขอจะถูก redirect ไปยัง /login เพื่อยืนยันตัวตนก่อนเสมอ มันจึงตอบกลับด้วย 302 Found แล้วแนบ location เพื่อให้ Browser redirect ไปยัง /login และตามคาดย่อมหาไม่เจอ
การแก้ไข
Spring Security, สร้างบริการลงทะเบียน
ผมสร้าง package ใหม่ชื่อ system ทำหน้าที่จัดการระบบในเรื่องต่อไปนี้
- register
ภายใต้ system package สร้างคลาส SystemController
คลาสนี้ต้องการ request ขาเข้าที่เกี่ยวกับการลงทะเบียน ผมจึงสร้าง package ใหม่อีก ชื่อ request เพื่อประดิษฐ์ POJO ขาเข้า ภายใน package นี้สร้างคลาสชื่อ RegisterRequest
ทีนี้บอกกับ Spring Security ว่า จงอนุญาตให้เรียก /register ได้นะ อื่นๆนอกจาก path นี้ให้บังคับทำ Basic Authentication
คลาส CustomWebSecurityConfigurerAdapter เพิ่ม override เมธอด configure(HttpSecurity http)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/register").permitAll()
.anyRequest()
.authenticated()
.and()
.httpBasic();
}
rerun แล้วทดสอบเรียกด้วย
localhost:8080/register
และ
localhost:4200/api/register
อย่าลืมกำหนด Content-Type เป็น application/json ในส่วนของ Headers
จากที่ทำไปไม่ได้มีอะไรพิเศษ เพื่อนจะเห็นว่าเราเพียงส่งคำขอพร้อม JSON ไป backend แล้วนำไปสร้างเป็น User ใหม่ก่อนจะ response กลับมา กล่าวคือไม่ได้ register จริงๆ
การรักษาความปลอดภัย
พิจารณาคำสั่งต่อไปนี้ มันคืออะไร?
http.csrf().disable()
http มีไทป์เป็นคลาส HttpSecurity ซึ่งอยู่ในกลุ่ม org.springframework.security.config.annotation.web.builders คือเป็น builder ตัวหนึ่ง (Design Pattern) ที่เราจะต้องกำหนดค่าต่างๆให้มันเท่าที่ต้องการโดยวิธีการ . (dot) ไปเรื่อยๆ มีอะไรบ้างอันนี้ต้องอ่านเอกสารนะ
csrf ย่อมาจาก Cross-Site Request Forgery ง่ายๆว่าเป็นการขโมยสิทธิ์ของผู้ใช้ผ่านการทำกิจกรรมบางอย่างบนเว็บ ไม่ว่าจะเป็นการเขียน script หรือหลอกให้ใช้บริการที่ไม่ประสงค์ดีก็ได้ มีผู้รู้หลายท่านเขียนไว้ดีแล้ว ลองไปอ่านของเขากัน
แต่สำหรับผมที่ใช้ Angular และ Spring frameworks (เท่าที่อ่านมาคือตั้งแต่ Angular 2+ และ Spring 4+) เรื่องเหล่านี้ framework จัดการให้แล้ว ไปดูได้
Angular
Spring
คำสั่ง csrf().disable() ดังข้างต้นก็คือ “ปิดการป้องกัน CSRF” คือไม่ป้องกันนั่นแหละ
อ่าว แล้วแบบนี้มันจะปลอดภัยได้อย่างไร? คำตอบง่ายๆตอนนี้ก็คือ ไม่ปลอยภัยครับ
เอาใจมือใหม่ก่อน ปกติแล้วเมื่อเราสร้างเว็บขึ้นมาสักหน้าเนี่ย แล้วเราต้องการเผยแพร่มัน เราจะเอามันไปวางไว้บนเครื่องที่เรียกว่า Server เมื่อเว็บเราอยู่บน Server ก็สามารถเรียกหน้าดังกล่าวมาแสดงที่ Browser ได้ (จะขอเรียกว่า Client แทนนะ)
Client ส่งคำขอไปก่อนว่า GET index.html (หวังอยากได้หน้านี้)
Server รับคำขอนั้นมาแล้วก็ค้นดูว่า index.html นี้มีอยู่ไหม มีก็ส่งให้ (OK 200) ไม่มีก็จะบอกว่าไม่เจอนะ (404 NotFound)
แล้วทุกอย่างก็จบลง การคุยกันระหว่าง Client กับ Server มีเท่านี้
คำถามคือถ้าเกิดว่าสิ่งที่ Client ต้องการ มันต้องได้มาด้วยสิทธิ์ที่คู่ควรล่ะ? ตัวอย่าง ชำระค่าบริการออนไลน์ ค่าน้ำ ค่าไฟ ค่าโทรศัพท์ ค่าสินค้าหรืออื่นๆ จะเป็นไปได้ไหมที่เราจ่ายค่าน้ำแทนคนอื่น หรือจะเป็นไปได้ไหมที่คนอื่นมาจ่ายค่าไฟแทนเรา หรือจะเป็นไปได้ไหมที่เราจ่ายค่าสินค้าไปแล้วแต่กลายเป็นว่าโอนเงินนี้ให้ใครก็ไม่รู้ แล้วทางร้านก็บอกว่ายังไม่ได้รับเงินค่ะ นี่จึงเรียกว่าสิทธิ์ที่คู่ควร คือเราเท่านั้นที่สามารถทำธุรกรรมเหล่านี้ได้ จึงเกิดการสร้างระบบยืนยันตัวตนขึ้นมา พื้นฐานก็จะให้ใส่ชื่อผู้ใช้กับรหัสผ่าน เรียกว่า Basic Authentication
Client ทำ Basic Authentication กับทาง Server เป็นผลให้ Server ทราบว่าที่กำลังคุยอยู่คือใคร น้าเป็ด พี่ไก่หรือไอ้แจ๊ก
เมื่อเป็นอย่างนี้ Client ก็จะต้องทำ Basic Authentication กับ Server อยู่ทุกครั้งที่คุยกัน มันส์ไหม? Client ตอบว่า ไม่เลย
จึงเกิดคำถามว่า ทำอย่างไรจะให้ Server รู้ว่าใครกันที่กำลังคุยด้วย เป็น Client คนไหนหนอ น้าเป็ด พี่ไก่หรือไอ้แจ๊ก โดยไม่ต้องทำ Basic Authentication ทุกครั้งที่คุย
จึงเกิดการสร้าง Cookie เพื่อจัดการกับปัญหานี้
วิธีการคือเมื่อ Client ร้องขอมาที่ Server, Server จะมอบขนมให้ เรียกว่า Cookie ส่งกลับไปเก็บที่ Client และเมื่อ Client ร้องขอครั้งที่สองมายัง Server ขนมชิ้นนี้ก็จะติดมาด้วยเสมอ ขนมชิ้นนี้แหละที่บรรจุข้อมูลต่างๆของผู้ใช้เอาไว้รวมถึงสิทธิ์เพื่อดำเนินธุรกรรมกับเว็บนั้นด้วย สุดยอดไปเลย
มันเป็นการแก้ปัญหาที่รวดเร็วและราคาถูก เพราะเมื่อข้อมูลของผู้ใช้ถูกขโมยไปใช้ในทางไม่ชอบต่างๆ Cookie นี้จึงกลายเป็นปัญหาใหญ่ทันที
ลองดู Cookie ที่ Facebook ใช้เมื่อเราไม่ได้ login กับที่ logged in แล้วนะครับ
ผู้ใช้ชอบ logged in ค้างไว้ ข้อมูลก็อยู่ใน Cookie นี้แหละจนกว่ามันจะหมดอายุไปเองหรือจนกว่าจะถูกลบ ผู้ไม่ประสงค์ดีก็คิดว่าเอ้…ขโมยซะเลย อิอิอิ
เครครับ ผมหยุดเท่านี้ หากเกิดความสนใจบ้างแล้วก็ขอให้กลับขึ้นไปอ่านลิงค์ที่ได้แปะไว้นั่นนะครับ การโจมตีโดยผู้ไม่ประสงค์ดีมีหลายรูปแบบ ตรงนี้เรา focus ที่ CSRF ก่อน
ผมขอกลับมาที่โค้ดนี้
http.csrf().disable()
กล่าวคือไม่ป้องกันการโจมตีด้วย CSRF อย่างนั้นเราต้องปิดมันอยู่ไหม?
โดย default แล้ว Spring Security จะทำทุกอย่างเพื่อรักษาความปลอดภัยให้ resources ย้ำนะครับ ทันทีที่เราใช้ spring boot starter security มันจะปกป้อง resources ให้เราทันที
งั้นสน่ห์ของ Spring Security อยู่ตรงไหน? อยู่ที่การเปลี่ยนแปลงการกำหนดค่าจากเดิมของมันให้เป็นไปตามความต้องการของระบบเราไง
กลับมาที่ผม ตอนนี้ถามผมมว่าอยากให้ระบบนี้เป็นอย่างไร ผมตอบว่า
- อยากให้ใช้ session ซึ่งแน่นอนว่ามีแล้วในรูปของ JSESSIONID ได้มาหลังจากคุยกับ Server หนแรก
- ผมอยากได้ POST /register กับ POST /login ให้ใส่ email กับ password ทว่าขณะนี้มีแค่ POST /register
- ของแถมคือผมมี GET /users จะเรียกได้เมื่อทำ Basic Authentication
ก่อนจะไปต่ออยากให้สังเกตครับว่า ถ้าเราไม่ปรับแต่ง Spring Security ด้วย csrf().disable() มันก็คือ enable การป้องกันการโจมตีรูปแบบ CSRF ตัว Server (ในที่นี้คือ Tomcat Server) จะถามหา CSRF Token และหากเราไม่ส่งให้มันก็จะบอกว่า 403 Forbidden
ตัวอย่างการ enable CSRF (โดย default)
http
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/register").permitAll()
.anyRequest()
.authenticated()
.and()
.httpBasic();
เพื่อ enable CSRF จึงต้องบอกกับ Spring Security ว่าให้ตรวจสอบ CSRF Token นะ
http
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/register")
.permitAll()
.anyRequest()
.authenticated()
.and()
.httpBasic();
จากข้างต้น พูดได้ว่า
- ไม่มี Session ID ก็ต้องทำ Basic Authentication
- การจะได้มาซึ่ง Basic Authentication จะปรากฏ dialog ถามบน Browser
- JSESSIONID หรือ Session ID ที่ได้จาก Server เก็บไว้ใน Cookie
- แต่เมื่อไรก็ตามบอกว่า CSRF Token ก็จะได้ XSRF-TOKEN เก็บไว้ใน Cookie ด้วย (your server needs to set a token in a JavaScript readable session cookie called
XSRF-TOKEN
on either the page load or the first GET request) - Cookie จะถูกส่งไปเก็บไว้กับ Client
- การคุยกันครั้งถัดไปจะใช้ JSESSIONID หรือ XSRF-TOKEN แทนการทำ Basic Authentication ซึ่งจะส่งมากับ Request Header
- ยกเว้น POST /register จะไม่ทำ Basic Authentication ทว่าต้องการ CSRF Token
ทดสอบ
รูปด้านล่างนี้ได้ลบ Cookie และเรียก /register ดังนั้นในหนแรกไม่มี CSRF Token ใน Request Header จึงมี dialog ปรากฏขึ้นเพื่อถามถึง Basic Authentication แต่เมื่อเรียก /register หลังจากมี CSRF Token แล้วทุกอย่างก็เรียบร้อยดี
Spring Security, สร้างบริการลงทะเบียน (ต่อ)
ณ บริการ /register ผมจะบันทึก email และ password ลงฐานข้อมูลครับ
โค้ดข้างต้นนี้จะดูก่อนว่า email ที่ต้องการลงทะเบียนมีอยู่แล้วในฐานข้อมูลหรือไม่ ถ้ามีแล้ว (optional.isPersent is true) ก็จะบอกว่าการลงทะเบียนนี้ไม่สำเสร็จนะ (HttpStatus.CONFLICT is 409) พร้อมกับแจ้ง message กลับไปด้วย ตรงกันข้ามก็จะนำ email กับ password นี้มาสร้างเป็น User เข้ารหัส password แล้วจะบันทึกลงฐานข้อมูล (userRepository.save) จากนั้นจึงตอบกลับ Client ด้วย 201 CREATE
สืบเนื่องจากโค้ดการ encode ด้วย PasswordEncoder มีใช้หลายแหล่ง ผมจึงสร้าง package ชื่อ config ไว้รวบรวม ให้ชื่อคลาสว่า Config
Angular หน้า Register เพิ่ม Validations
ปรับปรุง register.component.html และ register.component.ts เพิ่มการตรวจสอบฟิลด์ดังนี้
- require ผู้ใช้ต้องกรอก email และ password
- รูปแบบ email ถูกต้อง
- ความยาวของ password อย่าวน้อย 4 ตัวอักษรขึ้นไป
จากตรงนี้ผมให้โจทย์ว่า หากระบุ email ที่มีอยู่แล้ว ย่อมได้ message ตอบกลับจาก Server ทำนองว่า “test@gmail.com is already registered” ตรงนี้จะแจ้งแก่ผู้ใช้อย่างไร? ทิ้งไว้ให้คิดนะครับ
ถัดไป
Spring Security, สร้างบริการเข้าสู่ระบบ
เริ่มจาก package ชื่อ request สร้างคลาสชื่อ LoginRequest
เพิ่มบริการ /login
@PostMapping("/login")
public ResponseEntity login(@Valid @RequestBody LoginRequest request) {
logger.info("Do Login...");
return new ResponseEntity(HttpStatus.OK);
}
ทดลองเรียก
401 Unauthorized อื่ม…
แก้ไขเมธอด configure(HttpSecurity http) ไฟล์ CustomWebSecurityConfigurerAdapter เพิ่ม
.antMatchers(HttpMethod.POST, "/register", "/login")
ทดลองเรียก
เฉพาะบริการ /register กับ /login ไม่ต้องทำ Basic Authentication นอกนั้นไม่ทำไม่ได้ เช่น /users
จึงต้องมีกลไกเพิ่มเพื่อบอกกับ Spring Security ว่าผู้ใช้คนนี้ได้เข้าสู่ระบบแล้วอย่างถูกต้อง เรามาดูความคิดของ Spring Security ที่มีต่อเรื่องที่เราจะทำนี่ก่อนนะครับ
หัวใจของภาพข้างต้นนี้คือ Security Context
ทิศทาง