ผลต่างระหว่างรุ่นของ "01204223/authentication"
Jittat (คุย | มีส่วนร่วม) |
Jittat (คุย | มีส่วนร่วม) |
||
| (ไม่แสดง 38 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน) | |||
| แถว 1: | แถว 1: | ||
: ''ส่วนหนึ่งของวิชา [[01204223]]'' | : ''ส่วนหนึ่งของวิชา [[01204223]]'' | ||
| + | |||
| + | การวางโครงให้กับการ authentication ครั้งแรก ๆ อาจจะดูยุ่งยากและซับซ้อน แต่ถ้าเราทำให้เป็นระบบ การทำงานต่อ ๆ ไปจะทำได้ง่าย เป็นการลงแรงครั้งแรกครั้งเดียว เพื่อความปลอดภัยและความสะดวกในอนาคต | ||
ในโครงงานของเรา เราจะใช้การทำ authentication ด้วย Token อย่างไรก็ตาม ยังมีวิธีที่ทำได้อีกหลายวิธี ด้านล่างเป็นความรู้พื้นฐานที่อ่านประกอบได้ | ในโครงงานของเรา เราจะใช้การทำ authentication ด้วย Token อย่างไรก็ตาม ยังมีวิธีที่ทำได้อีกหลายวิธี ด้านล่างเป็นความรู้พื้นฐานที่อ่านประกอบได้ | ||
| แถว 97: | แถว 99: | ||
และอย่าลืม export / set FLASK_APP เป็น main.py ด้วย | และอย่าลืม export / set FLASK_APP เป็น main.py ด้วย | ||
| − | === การ hash ด้วย | + | === การ hash ด้วย Bcrypt === |
เมื่อ activate venv แล้ว เราจะติดตั้ง library [https://flask-bcrypt.readthedocs.io/en/1.0.1/ Flask-Bcrypt] สำหรับ hash รหัสผ่าน โดยสั่ง | เมื่อ activate venv แล้ว เราจะติดตั้ง library [https://flask-bcrypt.readthedocs.io/en/1.0.1/ Flask-Bcrypt] สำหรับ hash รหัสผ่าน โดยสั่ง | ||
| แถว 140: | แถว 142: | ||
>>> check_password_hash(pw,'hello') | >>> check_password_hash(pw,'hello') | ||
False | False | ||
| + | |||
| + | {{กล่องเทา| | ||
| + | ปัจจุบัน มาตรฐานจะใช้ hash [https://en.wikipedia.org/wiki/Argon2 argon2] มากกว่า จะปรับเอกสารต่อไป ขอบคุณมนัสวินที่แจ้งมาด้วยครับ | ||
| + | }} | ||
=== User model === | === User model === | ||
| แถว 181: | แถว 187: | ||
=== Registration CLI === | === Registration CLI === | ||
| + | |||
| + | เพื่อความสะดวก เราจะยังไม่ทำหน้าลงทะเบียนผ่านทาง react ui แต่เราจะเขียนคำสั่งใน Flask สำหรับสร้าง user ไว้ก่อน เราจะได้มี user ไว้ทดสอบได้ | ||
| + | |||
| + | เราสามารถเพิ่มคำสั่งให้กับ flask ได้ คำสั่งเหล่านี้เมื่อเพิ่มแล้ว เราสามารถเรียกผ่าน flask ได้เลย ตัวอย่างเช่น เดี๋ยวเราจะเพิ่มคำสั่ง create-user ซึ่งจะทำให้เราสั่ง | ||
| + | |||
| + | flask create-user admin "Admin Fullname" helloworld | ||
| + | |||
| + | เพื่อสร้าง user ที่มี username=admin, full_name="Admin Fullname" และ password=helloworld ได้ | ||
| + | |||
| + | Flask ใช้ไลบรารีชื่อ click ในการจัดการกับ cli ดังนั้นเราจะต้องไป import มาก่อน ให้เพิ่มบรรทัดข้างล่างที่ตอนต้น main.py | ||
| + | |||
| + | import click | ||
| + | |||
| + | จากนั้นเราจะไปเพิ่มฟังก์ชันสำหรับทำงานตามคำสั่งดังกล่าวใน main.py (ควรไว้ตอนท้ายเลย) แต่เราจะครอบไว้ด้วย @app.cli.command และ @click | ||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 191: | แถว 211: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| + | การระบุดังกล่าว บอก flask ว่าจะมีคำสั่งใหม่เพิ่มขึ้น รวมทั้งระบุ argument ด้วย เมื่อแก้แล้ว ทดลองเรียกคำสั่ง create-user ในรูปแบบต่าง ๆ ดู ก่อนจะแก้ต่อไป | ||
| + | |||
| + | เราจะต้อง import User มาจาก models ก่อน โดยเพิ่มบรรทัดไว้ที่ตอนต้น main.py | ||
| + | |||
| + | <syntaxhighlight lang="python"> | ||
| + | from models import User | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เราจะเพิ่มโค้ดในการสร้าง user รวมทั้งตรวจสอบเพื่อป้องกันการสร้าง user ซ้ำลงในฟังก์ชัน create_user ดังด้านล่าง | ||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 208: | แถว 237: | ||
click.echo(f"User {username} created successfully.") | click.echo(f"User {username} created successfully.") | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| + | |||
| + | ให้ทดลองสร้าง user สัก 1 - 2 คน ให้ทดลองสั่งสร้าง username ซ้ำดูเพื่อตรวจสอบความถูกต้อง | ||
| + | |||
| + | ในการทดลองต่อไป เราจะต้อง login user คนที่เพิ่งสร้างมา ดังนั้นอย่าลืมจำรหัสผ่านไว้ด้วย ถ้าจำไม่ได้สามารถกลับมาสร้าง user เพื่อทดลองใหม่ได้ | ||
=== API === | === API === | ||
| − | เราจะเริ่มโดยการตรวจสอบรหัสผ่าน | + | เราจะเริ่มโดยการตรวจสอบรหัสผ่าน เพิ่มฟังก์ชันด้านล่างใน main.py |
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 226: | แถว 259: | ||
return jsonify({'message': 'Login successful'}) | return jsonify({'message': 'Login successful'}) | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| + | |||
| + | ถ้าเขียนเสร็จแล้ว ให้ทดลอง API ดังกล่าว ว่าสามารถตรวจสอบรหัสผ่านได้หรือไม่ (ทดลองใน browser extension) | ||
==== Token ==== | ==== Token ==== | ||
| + | |||
| + | เราจะเพิ่มความสามารถในการสร้างและตรวจสอบ JWT | ||
เราจะใช้ไลบรารี [https://flask-jwt-extended.readthedocs.io/en/stable/ Flask-JWT-Extended] ในการสร้างและตรวจสอบ JSON Web Token (JWT) โดยติดตั้งดังนี้ | เราจะใช้ไลบรารี [https://flask-jwt-extended.readthedocs.io/en/stable/ Flask-JWT-Extended] ในการสร้างและตรวจสอบ JSON Web Token (JWT) โดยติดตั้งดังนี้ | ||
pip install flask-jwt-extended | pip install flask-jwt-extended | ||
| + | |||
| + | เราจะต้อง import function ที่เกี่ยวข้องไว้ตอนต้นไฟล์ main.py | ||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 237: | แถว 276: | ||
from flask_jwt_extended import JWTManager | from flask_jwt_extended import JWTManager | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| + | |||
| + | เราจะต้องตั้งค่าเพิ่มต้นของ jwt โดยป้อนกุญแจลับ ใส่โค้ดดังกล่าว หลังบรรทัด <tt>app.config['SQLALCHEMY_DATABASE_URI']</tt> กุญแจลับดังกล่าว (JWT_SECRET_KEY) ใช้ในการลงลายชื่อรับรองว่า token เป็น token ที่สร้างจาก server ของเรา | ||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 243: | แถว 284: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| − | ==== | + | ==== Login API ==== |
| + | |||
| + | เราจะไปแก้ฟังก์ชัน login ให้สร้าง token และคืนค่ากลับไปเป็น json โดยแก้ฟังก์ชัน login ให้เป็นดังนี้ | ||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 256: | แถว 299: | ||
return jsonify({'error': 'Invalid username or password'}), 401 | return jsonify({'error': 'Invalid username or password'}), 401 | ||
| + | // ***** สร้าง token แล้วคืนค่ากลับไป | ||
access_token = create_access_token(identity=user.username) | access_token = create_access_token(identity=user.username) | ||
return jsonify(access_token=access_token) | return jsonify(access_token=access_token) | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| + | |||
| + | ให้ทดลองเรียก API ดังกล่าว ดูว่าเห็น token หรือไม่ ถ้าทำงานได้จะเห็นดังรูปด้านล่าง ที่มี token คืนกลับมา | ||
| + | |||
| + | [[Image:223-api-gen-token.png|400px]] | ||
==== อะไรอยู่ใน token ==== | ==== อะไรอยู่ใน token ==== | ||
| + | ให้ลอง copy token ที่ได้ ไปลองกับเว็บ [https://www.jwt.io/ https://www.jwt.io/] เพื่อ decode และตรวจสอบ signature อย่าลืมใส่กุญแจลับ ('''ห้ามใช้กับกุญแจลับจริงๆ''') | ||
==== การป้องกัน route ใน Flask ==== | ==== การป้องกัน route ใน Flask ==== | ||
| + | |||
| + | เราสามารถใช้ jwt_required ในการป้องกัน route ใน Flask ได้ โดยเพิ่ม decorator อย่างเช่นบรรทัด <tt>@jwt_required()</tt> ไว้ถัดจาก <tt>@app.route</tt> ดังด้านล่าง | ||
<syntaxhighlight lang="python"> | <syntaxhighlight lang="python"> | ||
| แถว 272: | แถว 323: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| − | ในที่นี้เรายังไม่ได้นำข้อมูลอะไรของผู้ใช้ไปทำอะไรเราเลยไม่ได้มีการแกะข้อมูลมาจาก JWT อย่างไรก็ตาม ถ้าเราต้องการข้อมูล เราจะใช้ฟังก์ชัน get_jwt_identity | + | ให้ทดลองเรียก api ดังกล่าว ควรจะต้องไม่สามารถเรียกได้ |
| + | |||
| + | ถ้าจะเรียกให้ผ่าน จะต้องเพิ่ม request header โดยเพิ่ม header | ||
| + | |||
| + | * <tt>Authorization</tt> โดยให้ค่าเป็น Bearer ''token'' | ||
| + | |||
| + | โดยแทน token ด้วย access token ที่ได้รับมา | ||
| + | |||
| + | ให้ไปเพิ่ม <tt>@jwt_required()</tt> decorator ให้กับทุก ๆ api end point (ที่ไม่ใช่ login) | ||
| + | |||
| + | '''หมายเหตุ''' ในที่นี้เรายังไม่ได้นำข้อมูลอะไรของผู้ใช้ไปทำอะไรเราเลยไม่ได้มีการแกะข้อมูลมาจาก JWT อย่างไรก็ตาม ถ้าเราต้องการข้อมูล เราจะใช้ฟังก์ชัน <tt>get_jwt_identity</tt> | ||
=== จัดการรายการ library และ commit === | === จัดการรายการ library และ commit === | ||
| แถว 324: | แถว 385: | ||
} | } | ||
} | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | {{กล่องเทา| | ||
| + | อย่างไรก็ตาม ถ้ารันเทส | ||
| + | |||
| + | npm test | ||
| + | |||
| + | ทุกอย่างจะยังสามารถทำงานได้ตามปกติ ลองหาคำตอบเองดูว่าทำไมการทำ authentication ที่เราเขียนจึงไม่มีผลกับ unit test | ||
| + | }} | ||
| + | |||
| + | === แยก TodoList === | ||
| + | ก่อนที่เราจะเริ่มทำอะไรซับซ้อน เราจะแยกส่วน TodoList ทั้งหมดออกมาจาก App เพราะว่าในตอนสุดท้ายเราจะนำส่วน authentication ไปครอบทุกอย่างไว้ แต่ถ้างานของเราอยู่ใน App เราจะไปหาอะไรมาครอบ App อีกจะทำไม่ได้ | ||
| + | |||
| + | ดังนั้นในขั้นแรกเราจะย้ายทุกอย่างที่เป็นการทำงานเกี่ยวกับ TodoList ออกจาก App ไปสร้างเป็น Component ใหม่ อย่างไรก็ตาม เราจะพยายามเก็บพวก config เช่น url ต่าง ๆ ไว้ใน App | ||
| + | |||
| + | เราจะย้ายโค้ดเดิมจาก App.jsx ไปสร้างเป็น TodoList.jsx ดังตัวอย่างด้านล่าง | ||
| + | |||
| + | หลัก ๆ ที่เราต้องแก้คือเปลี่ยนชื่อฟังก์ชันจาก App เป็น TodoList และใส่ prop apiUrl | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import { useState, useEffect } from 'react' | ||
| + | import './App.css' | ||
| + | |||
| + | import TodoItem from './TodoItem.jsx' | ||
| + | |||
| + | function TodoList({apiUrl}) { | ||
| + | const TODOLIST_API_URL = apiUrl; | ||
| + | |||
| + | const [todoList, setTodoList] = useState([]); | ||
| + | const [newTitle, setNewTitle] = useState(""); | ||
| + | |||
| + | // ... โค้ดเดิม | ||
| + | } | ||
| + | |||
| + | export default TodoList; // อย่าลืมแก้ export | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นเราจะทำความสะอาด App.jsx ของเรา โดยนำส่วนที่ย้ายออกไปแล้วทิ้งไป | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import { BrowserRouter, Routes, Route } from "react-router-dom"; | ||
| + | import { AuthProvider } from './context/AuthContext.jsx'; | ||
| + | import './App.css' | ||
| + | |||
| + | import TodoList from './TodoList.jsx' | ||
| + | |||
| + | function App() { | ||
| + | const TODOLIST_API_URL = 'http://localhost:5000/api/todos/'; | ||
| + | |||
| + | return ( | ||
| + | <TodoList apiUrl={TODOLIST_API_URL}/> | ||
| + | ) | ||
| + | } | ||
| + | |||
| + | export default App | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| แถว 376: | แถว 492: | ||
path="/" | path="/" | ||
element={ | element={ | ||
| − | < | + | <TodoList apiUrl={TODOLIST_API_URL}/> |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
} | } | ||
/> | /> | ||
| แถว 402: | แถว 510: | ||
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| + | |||
| + | และจะไปเพิ่มลิงก์ไปยังหน้า About จาก TodoList ในไฟล์ TodoList.jsx component ดังด้านล่าง | ||
| + | |||
| + | <syntaxhighlight> | ||
| + | function TodoList({apiUrl}) { | ||
| + | // ... ละส่วนบนไว้ ... | ||
| + | |||
| + | return ( | ||
| + | <> | ||
| + | .... ละไว้ .... เพิ่มบรรทัดด้านล่าง | ||
| + | |||
| + | <br/> | ||
| + | <a href="/about">About</a> | ||
| + | </> | ||
| + | ) | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
เราจะสามารถลองกดไปมาระหว่างหน้าหลัก กับ About ได้ | เราจะสามารถลองกดไปมาระหว่างหน้าหลัก กับ About ได้ | ||
| แถว 408: | แถว 534: | ||
=== Login form === | === Login form === | ||
| + | เราจะเพิ่ม component LoginForm และนำไปเพิ่มเป็นหนึ่งใน Route | ||
| + | |||
| + | เพิ่มไฟล์ frontent/src/LoginForm.jsx ดังด้านล่าง ฟอร์มดังกล่าวจะรับ prop loginUrl สำหรับการ login และขอ access token | ||
| + | |||
| + | โค้ดด้านล่างจะยังไม่ทำงานอะไร โดยเราจะเพิ่มลงไปใน route ให้เสร็จก่อน | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import { useState } from 'react' | ||
| + | import './App.css' | ||
| + | |||
| + | function LoginForm({loginUrl}) { | ||
| + | const [username, setUsername] = useState(""); | ||
| + | const [password, setPassword] = useState(""); | ||
| + | |||
| + | async function handleLogin(e) { | ||
| + | e.preventDefault(); | ||
| + | } | ||
| + | |||
| + | return ( | ||
| + | <form onSubmit={(e) => {handleLogin(e)}}> | ||
| + | Username: | ||
| + | <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} /> | ||
| + | <br/> | ||
| + | Password: | ||
| + | <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> | ||
| + | <br/> | ||
| + | <button type="submit">Login</button> | ||
| + | </form> | ||
| + | ); | ||
| + | } | ||
| + | |||
| + | export default LoginForm; | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เพิ่ม component ดังกล่าว ลงใน App ดังนี้ | ||
| + | |||
| + | ขั้นแรกเราต้องไป import LoginForm ที่ตอนต้นโค้ดใน App.jsx | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import LoginForm from './LoginForm.jsx'; | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นเราไปเพิ่มค่าคงที่ของ URL สำหรับการ login | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | function App() { | ||
| + | // ... | ||
| + | const TODOLIST_LOGIN_URL = 'http://localhost:5000/api/login/'; | ||
| + | // ... | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นให้เพิ่ม Login เข้าไปเป็นอีกหนึ่งใน Route ใน Routes | ||
| + | |||
| + | <syntaxhighlight lang="html"> | ||
| + | <Routes> | ||
| + | .... | ||
| + | |||
| + | <Route | ||
| + | path="/login" | ||
| + | element={ | ||
| + | <LoginForm loginUrl={TODOLIST_LOGIN_URL} /> | ||
| + | } | ||
| + | /> | ||
| + | </Routes> | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ลอง refresh ที่หน้าหลัก จากนั้นลองไปที่ url /login ดูว่าฟอร์ม login ปรากฏขึ้นหรือไม่ | ||
| + | |||
| + | เราจะไปเพิ่มลิงก์สำหรับ Login ไว้ที่หน้า TodoList ด้วยเพื่อความสะดวกในการทดสอบ โดยในไฟล์ TodoList.jsx ให้เพิ่มบรรทัดด้านล่างลงไปใน (ต่อจากลิงก์ไป About) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | function TodoList({apiUrl}) { | ||
| + | // ... | ||
| + | |||
| + | return ( | ||
| + | <> | ||
| + | .... | ||
| + | <a href="/about">About</a> | ||
| + | <!--- **** เพิ่มบรรทัดด้านล่าง **** ---> | ||
| + | <br/> | ||
| + | <a href="/login">Login</a> | ||
| + | </> | ||
| + | ) | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ถ้าเราสามารถทำให้ Login form แสดงได้อย่างถูกต้องแล้ว ให้เพิ่มโค้ดสำหรับการเรียก API โดยแก้ฟังก์ชัน handleLogin ใน LoginForm.jsx ให้เป็นดังด้านล่าง | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | async function handleLogin(e) { | ||
| + | e.preventDefault(); | ||
| + | try { | ||
| + | const response = await fetch(loginUrl, { | ||
| + | method: 'POST', | ||
| + | headers: { | ||
| + | 'Content-Type': 'application/json', | ||
| + | }, | ||
| + | body: JSON.stringify({ username: username, password: password }), | ||
| + | }); | ||
| + | if (response.ok) { | ||
| + | const data = await response.json(); | ||
| + | console.log(data); | ||
| + | alert("Login successful. access token = " + data.access_token); | ||
| + | } else if (response.status === 401) { | ||
| + | alert("Login failed: Invalid username or password"); | ||
| + | } | ||
| + | } catch (error) { | ||
| + | console.log("Error logging in:", error); | ||
| + | } | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ลองกด login ทั้งที่รหัสผ่านถูกต้องและผิดพลาด เพื่อทดสอบว่าทำงานได้ถูกต้อง | ||
| + | |||
| + | เราอาจจะไม่ชอบเห็น alert เยอะๆ ยิ่งโดยเฉพาะตอนที่เราพิมพ์รหัสผ่านผิด เราจะปรับโค้ดให้มีการแสดงเป็นข้อความแทน ใน React สามารถทำได้โดยสะดวก | ||
| + | |||
| + | เพิ่ม state errorMessage ในตอนต้น LoginForm | ||
| + | <syntaxhighlight lang="javascript"> | ||
| + | const [errorMessage, setErrorMessage] = useState(""); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | แก้โค้ด แทนที่จะ alert ให้ปรับ state ของตัวแปร errorMessage แทน | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | if (response.ok) { | ||
| + | //... | ||
| + | } else if (response.status === 401) { | ||
| + | setErrorMessage("Invalid username or password"); | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | แก้ในส่วน jsx html สำหรับแสดงผล ให้เพิ่มการแสดงผล errorMessage แบบมีเงื่อนไขดังด้านล่าง | ||
| + | |||
| + | <syntaxhighlight lang="html"> | ||
| + | <form onSubmit={(e) => {handleLogin(e)}}> | ||
| + | <!--- **** เพิ่มบรรทัดด้านล่าง ****** ---> | ||
| + | {errorMessage && <p>{errorMessage}</p>} | ||
| + | ... | ||
| + | </form> | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ทดลองเพื่อตรวจสอบความถูกต้อง | ||
| + | |||
| + | เมื่อเราได้ access token มาแล้ว เราจะต้องหาวิธีส่ง token ดังกล่าวให้กับส่วนอื่น ๆ ของ App ซึ่งถ้าเราใช้การส่งค่าผ่านทาง props มันจะเป็นการส่งที่กระจายไปทั่วโค้ดมาก ๆ ดังนั้นใน React จึงมีแนวคิดที่เรียกว่า Context ที่เราสามารถใช้เพื่อส่งข้อมูลที่มีลักษณะเป็นแบบ global ได้ | ||
| + | |||
| + | เราจะใช้ context ในการเก็บข้อมูลเกี่ยวกับ access token (และอาจจะรวมไปถึงข้อมูลอื่น ๆ ที่เกี่ยวข้องกับผู้ใช้) จากนั้นเราจะจัดการทำส่วน Router ให้ซ่อน Route ที่เข้าถึงไม่ได้โดยอัตโนมัติด้วยข้อมูลใน context นี้ | ||
=== Context === | === Context === | ||
| − | === | + | แนวคิดของ context คือเหมือนการสร้าง global state โดยมี Provider เป็นตัวจัดการ และ component ใดที่อยู่ภายใต้ Provider นั้นจะสามารถเรียก global state เหล่านั้นได้ โดยเรียกลักษณะเดียวกับการเรียก useState |
| + | |||
| + | ให้ไปสร้างไฟล์ frontent/src/context/AuthContext.jsx ดังด้านล่าง (อย่าลืมสร้าง directory context ก่อน) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import React, { createContext, useState, useContext } from "react"; | ||
| + | |||
| + | export const AuthContext = createContext(null); | ||
| + | |||
| + | export const AuthProvider = ({ children }) => { | ||
| + | const [username, setUsername] = useState(localStorage.getItem("username") || null); | ||
| + | const [accessToken, setAccessToken] = useState(localStorage.getItem("accessToken") || null); | ||
| + | |||
| + | const login = (username, token) => { | ||
| + | localStorage.setItem("username", username); | ||
| + | localStorage.setItem("accessToken", token); | ||
| + | setAccessToken(token); | ||
| + | setUsername(username); | ||
| + | }; | ||
| + | |||
| + | const logout = () => { | ||
| + | localStorage.removeItem("username"); | ||
| + | localStorage.removeItem("accessToken"); | ||
| + | setAccessToken(null); | ||
| + | setUsername(null); | ||
| + | }; | ||
| + | |||
| + | return ( | ||
| + | <AuthContext.Provider value={{ username, accessToken, login, logout }}> | ||
| + | {children} | ||
| + | </AuthContext.Provider> | ||
| + | ); | ||
| + | }; | ||
| + | |||
| + | export const useAuth = () => useContext(AuthContext); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | โค้ดดังกล่าวแบ่งได้เป็นหลายส่วน เราจะสรุปรายละเอียดให้หลังจากที่เราเห็นการนำไปใช้แล้ว แต่สิ่งที่เราจะได้จากโค้ดนี้คือ component AuthProvider และฟังก์ชัน useAuth ที่เราจะนำไปใช้ดังนี้ | ||
| + | |||
| + | เราจะครอบ component ทั้งหมดใน component App ด้วย AuthProvider โดยแก้ไฟล์ App.jsx ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // *** เพิ่มการ import | ||
| + | import { AuthProvider } from './context/AuthContext.jsx'; | ||
| + | |||
| + | function App() { | ||
| + | // ... | ||
| + | |||
| + | return ( | ||
| + | <AuthProvider> | ||
| + | <!--- **** ย้าย BrowserRouter มาไว้ด้านใน AuthProvider **** ---> | ||
| + | <BrowserRouter> | ||
| + | <Routes> | ||
| + | <Route .... /> | ||
| + | <Route .... /> | ||
| + | </Routes> | ||
| + | </BrowserRouter> | ||
| + | </AuthProvider> | ||
| + | ) | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เมื่อทำเช่นนี้แล้วทุก ๆ component ที่ถูกครอบด้วย AuthProvider จะสามารถเรียก useAuth ได้ รวมทั้ง TodoList และ LoginForm | ||
| + | |||
| + | ==== LoginForm ==== | ||
| + | |||
| + | เราจะเรียกใช้ useAuth เพื่อให้เราสามารถเรียกฟังก์ชัน login เพื่อเก็บ username และ token ลง context ได้ใน LoginForm ให้แก้ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // *** เพิ่มบรรทัด import | ||
| + | import { useAuth } from "./context/AuthContext"; | ||
| + | |||
| + | function LoginForm({loginUrl}) { | ||
| + | // เรียก useAuth | ||
| + | const { login } = useAuth(); | ||
| + | |||
| + | async function handleLogin(e) { | ||
| + | e.preventDefault(); | ||
| + | try { | ||
| + | // ... | ||
| + | if (response.ok) { | ||
| + | const data = await response.json(); | ||
| + | console.log(data); | ||
| + | alert("Login successful. access token = " + data.access_token); | ||
| + | |||
| + | // เรียกฟังก์ชัน login เพื่อเก็บ username และ token | ||
| + | login(username, data.access_token); | ||
| + | } // ... else / | ||
| + | } | ||
| + | } | ||
| + | // ... | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เมื่อแก้แล้ว ให้ทดลอง login ดู ถ้ามี alert แสดง token แปลว่าสามารถ login ได้ แต่เราก็ยังดูยากอยู่ดีว่าได้มีการเก็บ username และ token ไปแล้ว | ||
| + | |||
| + | เราจะเพิ่มโค้ดอีกสักเล็กน้อย เพื่อทดสอบ และทำให้เราเห็นผลลัพธ์ชัดขึ้น เราจะนำ username ออกมาจาก useAuth ด้วย | ||
| + | |||
| + | อย่างไรก็ตาม โค้ด LoginForm ของเรามี const username สำหรับใช้ใน form อยู่แล้ว ถ้าเราเขียน | ||
| + | |||
| + | const { login, username } = useAuth(); | ||
| + | |||
| + | จะ error เพราะว่าชื่อ constant ชนกัน | ||
| + | |||
| + | เราจะต้อง destructure พร้อมเปลี่ยนชื่อ และนำค่าไปแสดงผล โดยแก้ไฟล์ LoginForm.jsx ให้เป็นดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | function LoginForm({loginUrl}) { | ||
| + | // เปลึ่ยนชื่อจาก username ให้เป็น loggedInUsername จาก useAuth | ||
| + | const { login, username: loggedInUsername } = useAuth(); | ||
| + | |||
| + | // ... | ||
| + | |||
| + | return ( | ||
| + | <form onSubmit={(e) => {handleLogin(e)}}> | ||
| + | .... | ||
| + | <button type="submit">Login</button> | ||
| + | <!--- *** เพิ่มบรรทัดด้านล่าง **** ---> | ||
| + | {loggedInUsername && <p>User {loggedInUsername} is already logged in.</p>} | ||
| + | </form> | ||
| + | ); | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เมื่อแก้เสร็จแล้ว ถ้าเรา login ได้เรียบร้อย จะมีข้อความแสดง is already logged in ด้านล่าง | ||
| + | |||
| + | ==== TodoList ==== | ||
| + | ในส่วนของ TodoList ก็จะมีการจัดการคล้ายกัน เราจะดำเนินการดังนี้ | ||
| + | * อ่าน username, accessToken, และ logout มาจาก useAuth | ||
| + | * เมื่อเรียก fetch จะนำ accessToken ไปส่งให้ backend | ||
| + | * แสดง username | ||
| + | * ถ้ามี username ให้แสดงปุ่ม Logout | ||
| + | |||
| + | โค้ด TodoList.jsx จะแก้เป็นดังนี้ (พร้อมคำอธิบาย) | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // **** เพิ่ม import | ||
| + | import { useAuth } from './context/AuthContext.jsx'; | ||
| + | import './App.css' | ||
| + | |||
| + | function TodoList({apiUrl}) { | ||
| + | // ** อ่านค่าจาก context | ||
| + | const { username, accessToken, logout } = useAuth(); | ||
| + | |||
| + | // ... | ||
| + | |||
| + | async function fetchTodoList() { | ||
| + | try { | ||
| + | // **** ในการเรียก fetch เราจะส่ง accessToken ไปทาง http header Authorization | ||
| + | const response = await fetch(TODOLIST_API_URL, { | ||
| + | headers: { | ||
| + | 'Authorization': `Bearer ${accessToken}` | ||
| + | } | ||
| + | }); | ||
| + | // .... | ||
| + | } catch (err) { | ||
| + | //alert("Failed to fetch todo list from backend. Make sure the backend is running."); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | // .... | ||
| + | |||
| + | return ( | ||
| + | <> | ||
| + | .... | ||
| + | <br/> | ||
| + | <a href="/about">About</a> | ||
| + | <br/> | ||
| + | {username && ( | ||
| + | <a href="#" onClick={(e) => {e.preventDefault(); logout();}}>Logout</a> | ||
| + | )} | ||
| + | </> | ||
| + | ) | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ในการทดลองโค้ดนี้ ให้ไป LoginForm ก่อน (พิมพ์ url /login) แล้วเมื่อ login เรียบร้อย ให้พิมพ์ url ของหน้าหลัก เพื่อดูรายการ จะเห็น TodoList ปรากฏ พร้อมด้วยชื่อ username | ||
| + | |||
| + | ถ้าเรากด Logout จะพบว่า username หายไป แต่ว่า TodoList ยังค้างอยู่ เพราะว่าเราไม่ได้ไปปรับ state นอกจากนี้ แม้เราจะไปบังคับให้ fetchTodoList ใหม่ แต่เรา try catch เอาไว้แล้ว ทำให้ค่าในรายการเดิมค้างอยู่ด้วย เราจะแก้ไขโดย | ||
| + | |||
| + | * ไปแก้ useEffect โดยระบุด้วยว่าถ้า username เปลี่ยนให้เรียก fetchTodoList (จะมีผลตอนหลัง logout) | ||
| + | * ปรับ try-catch ใน fetchTodoList ถ้าเกิด error ให้เคลียร์รายการ Todo | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | function TodoList({apiUrl}) { | ||
| + | // ... | ||
| + | |||
| + | useEffect(() => { | ||
| + | fetchTodoList(); | ||
| + | }, [username]); // *** เพิ่ม username ในรายการ | ||
| + | |||
| + | async function fetchTodoList() { | ||
| + | try { | ||
| + | // ... | ||
| + | } catch (err) { | ||
| + | //alert("Failed to fetch todo list from backend. Make sure the backend is running."); | ||
| + | |||
| + | setTodoList([]); // *** เคลียร์รายการ | ||
| + | } | ||
| + | } | ||
| + | |||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | อย่างไรก็ตาม app ของเรายังไม่มีการปิดกั้นการเรียก route ก่อนจะ login เลย เราจะจัดการในส่วนต่อไป | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | 🄶 ก่อนจะทำต่อ ให้ commit งานที่ทำ อย่าลืมเพิ่มไฟล์ src/TodoList.jsx และ src/context/AuthContext.jsx ด้วย | ||
| + | }} | ||
| + | |||
| + | ==== แล้ว context คืออะไร? ==== | ||
| + | |||
| + | : ''จะมาเขียนเพิ่มเติม ด้านล่างเป็นคำตอบจาก Claude'' | ||
| + | |||
| + | Context และ Provider คืออะไร? | ||
| + | |||
| + | * AuthContext คือ "ช่องทาง" หรือ "สาย" ที่ใช้ส่งข้อมูล — มันไม่ได้เก็บข้อมูลเองหรือทำอะไรมากนัก แค่เป็นตัวกลางที่ React ใช้รู้ว่าข้อมูลชุดนี้คือชุดเดียวกัน | ||
| + | |||
| + | * AuthProvider คือ component ที่ เป็นเจ้าของ state (user, accessToken) และ กระจายค่า เหล่านั้นออกไปให้ทุก component ที่อยู่ข้างใน | ||
| + | |||
| + | ถ้าจะเปรียบเทียบให้เห็นภาพ ให้ลองนึกถึง ระบบไฟฟ้าในบ้าน: | ||
| + | |||
| + | * AuthContext คือ สายไฟ — เป็นตัวกลางส่งกระแส | ||
| + | * AuthProvider คือ แหล่งจ่ายไฟ — เป็นคนผลิตและจ่ายกระแสไฟ | ||
| + | * useAuth() คือ ปลั๊กไฟ — component ไหนอยากใช้ก็เสียบได้เลย | ||
| + | |||
| + | === PrivateRoute === | ||
| + | เราจะสร้าง component PrivateRoute ในไฟล์ src/PrivateRoute.jsx เพื่อตรวจสอบว่าใน AuthContext มี accessToken หรือไม่ ถ้าไม่มีจะย้ายหน้าไป /login โดยอัตโนมัติ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | import { Navigate } from "react-router-dom"; | ||
| + | import { useAuth } from "./context/AuthContext"; | ||
| + | |||
| + | export default function PrivateRoute({ children }) { | ||
| + | const { accessToken } = useAuth(); | ||
| + | |||
| + | if (accessToken) | ||
| + | return children; | ||
| + | else | ||
| + | return ( | ||
| + | <Navigate to="/login" replace /> | ||
| + | ); | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | บรรทัดเงื่อนไข if บางคนอาจจะเขียนให้สั้นกว่านี้ได้เป็น | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | return (accessToken ? children : <Navigate to="/login" replace />); | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เราจะนำ component นี้ไปแทรกไว้ใน App ก่อนจะถึง TodoList ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // ** เพิ่มการ import | ||
| + | import PrivateRoute from "./PrivateRoute.jsx"; | ||
| + | |||
| + | function App() { | ||
| + | // ... | ||
| + | |||
| + | return ( | ||
| + | <AuthProvider> | ||
| + | <BrowserRouter> | ||
| + | <Routes> | ||
| + | <Route | ||
| + | path="/" | ||
| + | element={ | ||
| + | <PrivateRoute> <!--- *** นำมาครอบ TodoList ไว้ *** ---> | ||
| + | <TodoList apiUrl={TODOLIST_API_URL}/> | ||
| + | </PrivateRoute> | ||
| + | } | ||
| + | /> | ||
| + | ... | ||
| + | </BrowserRouter> | ||
| + | </AuthProvider> | ||
| + | ) | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ลอง logout ดูแล้ว refresh ที่หน้า / จะพบว่าเว็บ redirect ไปหาหน้า login โดยอัตโนมัติ | ||
| + | |||
| + | === เก็บกวาดงาน === | ||
| + | ตอนนี้สิ่งที่เราต้องทำคือการทำให้ redirect ไปหน้า TodoList หลัง login เสร็จ และการเพิ่ม authorization header ลงไปในทุก ๆ request | ||
| + | |||
| + | ==== Redirect ด้วย navigate ==== | ||
| + | |||
| + | เราจะ redirect โดยใช้ navigate จาก react-router-dom โดยดำเนินการแก้ LoginForm.jsx ดังนี้ | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | // *** เพิ่ม import | ||
| + | import { useNavigate } from 'react-router-dom' | ||
| + | |||
| + | function LoginForm({loginUrl}) { | ||
| + | // *** useNavigate เพื่อรับฟังก์ชันไว้ย้าย url | ||
| + | const navigate = useNavigate(); | ||
| + | |||
| + | async function handleLogin(e) { | ||
| + | // ... | ||
| + | try { | ||
| + | // ... | ||
| + | if (response.ok) { | ||
| + | const data = await response.json(); | ||
| + | console.log(data); | ||
| + | //alert("Login successful. access token = " + data.access_token); | ||
| + | login(username, data.access_token); | ||
| + | |||
| + | // *** หลังจาก login และเก็บ username กับ token แล้ว เพิ่มบรรทัดนี้ | ||
| + | navigate('/'); | ||
| + | } // .... | ||
| + | } // ... | ||
| + | } | ||
| + | // ... | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ==== เพิ่ม Authorization header ==== | ||
| + | |||
| + | เพื่อให้เราสามารถเรียกทุก api ได้แบบเดิม เราจะต้องไปเพิ่ม Authorization header ให้กับทุก ๆ การเรียก fetch ในฟังก์ชัน toggleDone, addNewTodo, deleteTodo และ addNewComment | ||
| + | |||
| + | มีวิธีการที่สะดวกกว่านี้ แต่เราจะขอละไว้ก่อน ตอนนี้ให้ไปปรับแก้ และทดลองว่าแอฟสามารถทำงานได้ | ||
| + | |||
| + | {{กล่องฟ้า| | ||
| + | '''🄶''' อย่าลืม commit งาน อย่าลืมเพิ่ม src/PrivateRoute.jsx เมื่อทำเสร็จแล้ว ให้ push กลับไปที่ github ด้วย | ||
| + | }} | ||
| + | |||
| + | == แก้ Unit test == | ||
| + | |||
| + | จากที่ตอนเริ่ม Unit test ต่าง ๆ ของเรายังสามารถทำงานได้ หลังจากแก้ไปมา เพิ่ม authentication ตอนนี้ไม่สามารถทำงานได้แล้ว เราจะมาแก้กัน | ||
| + | |||
| + | ขั้นแรก เนื่องจากเรา refactor component TodoList ออกมาจาก App เราจะแก้ unit test ของ App ให้เป็นของ TodoList โดยดำเนินการดังนี้ | ||
| + | |||
| + | * เปลี่ยนชื่อไฟล์ <tt>frontend/src/__test__/App.test.jsx</tt> เป็น <tt>frontend/src/__test__/TodoList.test.jsx</tt> | ||
| + | * แก้ component <code>App</code> เป็น <code>TodoList</code> ให้หมด ใน test ดังกล่าว | ||
| + | |||
| + | ให้ทดลองรัน <tt>npm test</tt> ดูผล จะได้ผลสรุปดังด้านล่าง | ||
| + | |||
| + | Test Files 1 failed | 1 passed (2) | ||
| + | Tests 2 failed | 6 passed (8) | ||
| + | Start at 02:41:36 | ||
| + | Duration 1.72s (transform 133ms, setup 323ms, import 210ms, tests 439ms, environment 1.55s) | ||
| + | |||
| + | ถ้าไปดูรายละเอียดจะพบ error เกี่ยวกับ useAuth | ||
| + | |||
| + | TypeError: Cannot destructure property 'username' of '(0 , __vite_ssr_import_2__.useAuth)(...)' as it is null. | ||
| + | ❯ TodoList src/TodoList.jsx:12:11 | ||
| + | 10| const [todoList, setTodoList] = useState([]); | ||
| + | 11| const [newTitle, setNewTitle] = useState(""); | ||
| + | 12| const { username, accessToken, logout } = useAuth(); | ||
| + | | ^ | ||
| + | 13| | ||
| + | |||
| + | และนี่คือประเด็นหลักที่เราแก้จากของเดิม | ||
| + | |||
| + | เนื่องจาก useAuth เป็นฟังก์ชันภายนอก เราจะต้อง mock ฟังก์ชันนี้เพื่อทำให้เราสามารถเทสได้ อย่างไรก็ตามฟังก์ชันดังกล่าวเกี่ยวกับ context และมีความซับซ้อน ทำให้การ mock ของเราจะยุ่งยากเล็กน้อย ให้ทำตามตัวอย่างด้านล่างได้เลย | ||
| + | |||
| + | ขั้นแรก เราจะต้องไป mock ไฟล์ ../context/AuthContext เพื่อให้คืนฟังก์ชัน useAuth ที่เราเตรียมไว้ และ import ฟังก์ชันนั้นมา ให้เพิ่มบรรทัดด้านล่าง ก่อนถึงบรรทัด describe('TodoList') | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | vi.mock('../context/AuthContext', () => ({ | ||
| + | useAuth: vi.fn(), | ||
| + | })); | ||
| + | |||
| + | import { useAuth } from '../context/AuthContext'; | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | จากนั้นใน beforeEach เราจะสั่งให้ถ้ามีการเรียกใช้ให้คืนค่าที่เราระบุ ดังด้านล่าง | ||
| + | |||
| + | <syntaxhighlight lang="javascript"> | ||
| + | describe('TodoList', () => { | ||
| + | beforeEach(() => { | ||
| + | vi.stubGlobal('fetch', vi.fn()); | ||
| + | useAuth.mockReturnValue({ | ||
| + | username: 'testuser', | ||
| + | login: vi.fn(), | ||
| + | logout: vi.fn(), | ||
| + | }); | ||
| + | }); | ||
| + | |||
| + | // ... | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | เมื่อเรา mock ฟังก์ชัน useAuth ได้แล้ว โค้ดสำหรับทดสอบที่เหลือก็จะสามารถทำงานได้ตามเดิม | ||
รุ่นแก้ไขปัจจุบันเมื่อ 19:48, 20 กุมภาพันธ์ 2569
- ส่วนหนึ่งของวิชา 01204223
การวางโครงให้กับการ authentication ครั้งแรก ๆ อาจจะดูยุ่งยากและซับซ้อน แต่ถ้าเราทำให้เป็นระบบ การทำงานต่อ ๆ ไปจะทำได้ง่าย เป็นการลงแรงครั้งแรกครั้งเดียว เพื่อความปลอดภัยและความสะดวกในอนาคต
ในโครงงานของเรา เราจะใช้การทำ authentication ด้วย Token อย่างไรก็ตาม ยังมีวิธีที่ทำได้อีกหลายวิธี ด้านล่างเป็นความรู้พื้นฐานที่อ่านประกอบได้
แนวทางการทำ Authentication สำหรับ Web Application
- เขียนโดย Gemini
การยืนยันตัวตน (Authentication) เป็นหัวใจสำคัญของความปลอดภัยใน Web Application บทความนี้จะอธิบายและเปรียบเทียบระหว่างสองแนวทางหลักที่นิยมใช้ในปัจจุบัน คือ Session-based และ Token-based
1. Session-based Authentication
เป็นวิธีการแบบดั้งเดิม (Traditional) ที่เน้นการเก็บสถานะการล็อกอินไว้ที่ฝั่ง Server (Stateful)
กระบวนการทำงาน (Workflow)
- User Login: ผู้ใช้ส่ง Username/Password จาก Frontend ไปยัง Backend
- Create Session: เมื่อตรวจสอบข้อมูลถูกต้อง Backend จะสร้าง "Session ID" และบันทึกข้อมูลผู้ใช้ (เช่น User ID, Role) ลงในหน่วยความจำของ Server หรือ Database
- Set Cookie: Backend ส่ง Session ID กลับมาหา Frontend ผ่านทาง Set-Cookie header (มักจะตั้งค่าเป็น HttpOnly เพื่อความปลอดภัย)
- Authenticated Request: ในทุกๆ Request ถัดไป Browser จะแนบ Cookie ที่มี Session ID ไปให้ Server โดยอัตโนมัติ
- Validation: Server ตรวจสอบ Session ID จาก Cookie เทียบกับข้อมูลใน Storage หากตรงกันถือว่ายืนยันตัวตนสำเร็จ
ข้อดีและข้อเสีย
| ข้อดี | ข้อเสีย |
|---|---|
|
|
2. Token-based Authentication (JWT)
เป็นวิธีการที่นิยมใน Modern Web App และ Microservices โดยเน้นการไม่เก็บสถานะที่ Server (Stateless) แต่ใช้การเข้ารหัสข้อมูลยืนยันตัวตนส่งไปให้ Client ถือไว้แทน (นิยมใช้มาตรฐาน JSON Web Token - JWT)
กระบวนการทำงาน (Workflow)
- User Login: ผู้ใช้ส่ง Username/Password ไปยัง Backend
- Generate Token: Backend ตรวจสอบข้อมูล หากถูกต้องจะสร้าง Token (String ยาวๆ ที่ผ่านการเข้ารหัสและลงลายเซ็น Digital Signature) ซึ่งบรรจุข้อมูลผู้ใช้ (Payload) ไว้ข้างใน
- Send Token: Backend ส่ง Token กลับไปให้ Frontend (ปกติส่งเป็น JSON response)
- Store Token: Frontend ต้องเขียนโค้ดเพื่อเก็บ Token เอง (เช่น เก็บใน LocalStorage หรือ Cookie)
- Authenticated Request: Frontend ต้องแนบ Token ไปใน HTTP Header (มักใช้ header:
Authorization: Bearer <token>) - Validation: Server ตรวจสอบความถูกต้องของลายเซ็น (Signature) ใน Token โดยใช้ Secret Key หากถอดรหัสได้และ Token ยังไม่หมดอายุ ก็จะอนุญาตให้เข้าถึงข้อมูล (ไม่ต้อง Query Database เพื่อหา Session)
ข้อดีและข้อเสีย
| ข้อดี | ข้อเสีย |
|---|---|
|
|
ตารางเปรียบเทียบ (Comparison)
| หัวข้อ | Session-based | Token-based (JWT) |
|---|---|---|
| State | Stateful (เก็บที่ Server) | Stateless (เก็บที่ Client/Token) |
| Storage | Server Memory / Database | Client (LocalStorage / Cookie) |
| Scalability | ยากกว่า (ต้องจัดการ Session Store) | ง่ายมาก |
| Logout/Revoke | ง่ายดาย (ลบ Session ทิ้ง) | ยาก (ต้องรอ Expire หรือทำ Blacklist) |
| เหมาะสำหรับ | Web App ทั่วไป, CMS, องค์กรที่เน้นความปลอดภัยสูง | SPA (Single Page App), Mobile App, API Service |
คำแนะนำเพิ่มเติมในการนำไปใช้:
- Session-based: เหมาะที่สุดสำหรับระบบหลังบ้าน (Back-office), ระบบการเงิน หรือเว็บที่ไม่ซับซ้อนมาก และต้องการควบคุมความปลอดภัยในการ Log out สูงสุด
- Token-based: เหมาะที่สุดสำหรับ Frontend ที่เป็น JavaScript Framework (React, Vue, Angular) หรือกรณีที่มี Mobile Application ใช้งาน API ร่วมด้วย
Backend
เราจะเริ่มจากส่วน backend ก่อน เราจะสร้างตารางเพื่อเก็บข้อมูล user ทั้ง username, ชื่อ รวมไปถึงรหัสผ่าน แต่เราจะไม่เก็บรหัสผ่าน ไว้ตรง ๆ เราจะเก็บรหัสผ่านที่ผ่านการ hashed แล้ว การ hash ทำให้เราสามารถตรวจสอบได้ว่ารหัสผ่านที่ผู้ใช้ป้อนนั้นถูกต้อง แต่ไม่สามารถสร้างรหัสผ่านย้อนกลับได้ ถ้าระบบเรารั่วไหล ข้อมูลรหัสผ่านของ user ก็จะยังปลอดภัย
เราจะเริ่มต้นจากโค้ดเก่า ใหั activate virtual environment ถ้าเป็น linux/mac เรียก
source ./venv/bin/activate
ถ้าเป็น windows เรียก
venv\Scripts\activate.bat
และอย่าลืม export / set FLASK_APP เป็น main.py ด้วย
การ hash ด้วย Bcrypt
เมื่อ activate venv แล้ว เราจะติดตั้ง library Flask-Bcrypt สำหรับ hash รหัสผ่าน โดยสั่ง
pip install flask-bcrypt
เราจะทดลองดูการ hash กันก่อน โดยเราจะสั่งใน python command line ให้เรียก python และทดลองตามด้านล่าง (เครื่องหมาย >>> คือ prompt ของ python)
เราจะเริ่มโดย import ฟังก์ชัน generate_password_hash และ check_password_hash
>>> from flask_bcrypt import generate_password_hash, check_password_hash
เราทดลองสั่งให้สร้าง password_hash ดังนี้
>>> generate_password_hash('helloworld')
เราจะได้ค่าคืนเป็น binary แทนรหัสผ่านที่ hash แล้ว ถ้าลองสั่งหลาย ๆ ครั้ง เราจะได้ค่าที่ไม่ซ้ำกัน
>>> generate_password_hash('helloworld')
b'$2b$12$l92BQ83xZ8FsanU8dfgxoet.6qFe65T2XkRARz71ZlHgUyHqiUZN6'
>>> generate_password_hash('helloworld')
b'$2b$12$EvXcgTF.AzWnlwewuyNHeOMQ0aaMemtkTmIakgNdkPznLTvaSJtI6'
>>> generate_password_hash('helloworld')
b'$2b$12$nAun8TkUFLM9qV221Grg.ucwRG.16lL3Dr/c4LBqMTgc2wdDKdbD2'
ทั้งนี้เนื่องจากฟังก์ชันไม่ได้นำรหัสผ่านไป hash เพียงอย่างเดียว แต่มีการสุ่ม salt ที่เป็นสตริงเพิ่มเติม ใส่ไปในการ hash ด้วย เพื่อป้องกันไม่ให้การตรวจสอบรหัสผ่านโดยอาศัยตารางทำได้โดยง่าย ถ้าลองดูรายละเอียด จะพบว่าสตริงที่ได้แบ่งได้เป็นส่วน ๆ (คั่นด้วย $) ดังนี้
- อัลกอริทึม: 2b
- จำนวนรอบ: 12
- salt: l92BQ83xZ8FsanU8dfgxoe (ยาว 22 ตัวอักษร)
- รหัสผ่าน+salt ที่ hash แล้ว: t.6qFe65T2XkRARz71ZlHgUyHqiUZN6
ดังนั้นในการตรวจสอบรหัสผ่าน เราจะต้องนำรหัสผ่านที่ผู้ใช้ป้อน และ password hash มาประมวลผล โดยแกะค่า salt จาก hash ก่อน จากนั้นนำมารวมกับรหัสผ่าน ก่อนจะทำไป hash อีกที
ทดลองตรวจรหัสผ่าน
>>> pw = generate_password_hash('helloworld')
>>> check_password_hash(pw,'helloworld')
True
>>> check_password_hash(pw,'goodmorning')
False
>>> check_password_hash(pw,'hello')
False
ปัจจุบัน มาตรฐานจะใช้ hash argon2 มากกว่า จะปรับเอกสารต่อไป ขอบคุณมนัสวินที่แจ้งมาด้วยครับ
User model
เราจะสร้างตารางเพื่อเก็บข้อมูลของผู้ใช้ ให้เพิ่มโมเดลลงในไฟล์ models.py ดังด้านล่าง เราจะเก็บ username, full_name และ hashed_password (เราจะไม่เก็บรหัสผ่านโดยตรง)
class User(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True)
full_name: Mapped[str] = mapped_column(String(200))
hashed_password: Mapped[str] = mapped_column(String(100))
จากนั้นไปเพิ่ม migration โดยสั่ง
flask db migrate -m "Add User"
และรัน migration เพื่อเพิ่มตารางในระบบ
flask db upgrade
ที่เราเพิ่มไปจะเป็นโมเดลเปล่า ๆ อย่างไรก็ตาม ปกติเรามักจะนำ function ที่เกี่ยวข้องกับโมเดลไปเขียนไว้ให้เป็น method ของโมเดลเลย ในที่นี้ ส่วนของการตั้งและตรวจสอบรหัสผ่านเป็นกิจกรรมที่ใกล้ชิดกับโมเดล User มาก ๆ ดังนั้น เราจะเพิ่ม method ที่เกี่ยวข้องลงไปในโมเดล User เลย
เราจะใช้ไลบรารี bcrypt ในการทำงานดังกล่าว ที่ตอนต้นไฟล์ models.py เพิ่มการ import ดังด้านล่าง
from flask_bcrypt import generate_password_hash, check_password_hash
จากนั้นเพิ่ม method เหล่านี้ลงไปใน class User
class User(db.Model):
// .. ละของเดิมไว้
def set_password(self, password):
self.hashed_password = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.hashed_password, password)
Registration CLI
เพื่อความสะดวก เราจะยังไม่ทำหน้าลงทะเบียนผ่านทาง react ui แต่เราจะเขียนคำสั่งใน Flask สำหรับสร้าง user ไว้ก่อน เราจะได้มี user ไว้ทดสอบได้
เราสามารถเพิ่มคำสั่งให้กับ flask ได้ คำสั่งเหล่านี้เมื่อเพิ่มแล้ว เราสามารถเรียกผ่าน flask ได้เลย ตัวอย่างเช่น เดี๋ยวเราจะเพิ่มคำสั่ง create-user ซึ่งจะทำให้เราสั่ง
flask create-user admin "Admin Fullname" helloworld
เพื่อสร้าง user ที่มี username=admin, full_name="Admin Fullname" และ password=helloworld ได้
Flask ใช้ไลบรารีชื่อ click ในการจัดการกับ cli ดังนั้นเราจะต้องไป import มาก่อน ให้เพิ่มบรรทัดข้างล่างที่ตอนต้น main.py
import click
จากนั้นเราจะไปเพิ่มฟังก์ชันสำหรับทำงานตามคำสั่งดังกล่าวใน main.py (ควรไว้ตอนท้ายเลย) แต่เราจะครอบไว้ด้วย @app.cli.command และ @click
@app.cli.command("create-user")
@click.argument("username")
@click.argument("full_name")
@click.argument("password")
def create_user(username, full_name, password):
print(username, full_name, password)
การระบุดังกล่าว บอก flask ว่าจะมีคำสั่งใหม่เพิ่มขึ้น รวมทั้งระบุ argument ด้วย เมื่อแก้แล้ว ทดลองเรียกคำสั่ง create-user ในรูปแบบต่าง ๆ ดู ก่อนจะแก้ต่อไป
เราจะต้อง import User มาจาก models ก่อน โดยเพิ่มบรรทัดไว้ที่ตอนต้น main.py
from models import User
เราจะเพิ่มโค้ดในการสร้าง user รวมทั้งตรวจสอบเพื่อป้องกันการสร้าง user ซ้ำลงในฟังก์ชัน create_user ดังด้านล่าง
@app.cli.command("create-user")
@click.argument("username")
@click.argument("full_name")
@click.argument("password")
def create_user(username, full_name, password):
user = User.query.filter_by(username=username).first()
if user:
click.echo("User already exists.")
return
user = User(username=username, full_name=full_name)
user.set_password(password)
db.session.add(user)
db.session.commit()
click.echo(f"User {username} created successfully.")
ให้ทดลองสร้าง user สัก 1 - 2 คน ให้ทดลองสั่งสร้าง username ซ้ำดูเพื่อตรวจสอบความถูกต้อง
ในการทดลองต่อไป เราจะต้อง login user คนที่เพิ่งสร้างมา ดังนั้นอย่าลืมจำรหัสผ่านไว้ด้วย ถ้าจำไม่ได้สามารถกลับมาสร้าง user เพื่อทดลองใหม่ได้
API
เราจะเริ่มโดยการตรวจสอบรหัสผ่าน เพิ่มฟังก์ชันด้านล่างใน main.py
@app.route('/api/login/', methods=['POST'])
def login():
data = request.get_json()
if not data or 'username' not in data or 'password' not in data:
return jsonify({'error': 'Username and password are required'}), 400
user = User.query.filter_by(username=data['username']).first()
if not user or not user.check_password(data['password']):
return jsonify({'error': 'Invalid username or password'}), 401
return jsonify({'message': 'Login successful'})
ถ้าเขียนเสร็จแล้ว ให้ทดลอง API ดังกล่าว ว่าสามารถตรวจสอบรหัสผ่านได้หรือไม่ (ทดลองใน browser extension)
Token
เราจะเพิ่มความสามารถในการสร้างและตรวจสอบ JWT
เราจะใช้ไลบรารี Flask-JWT-Extended ในการสร้างและตรวจสอบ JSON Web Token (JWT) โดยติดตั้งดังนี้
pip install flask-jwt-extended
เราจะต้อง import function ที่เกี่ยวข้องไว้ตอนต้นไฟล์ main.py
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
from flask_jwt_extended import JWTManager
เราจะต้องตั้งค่าเพิ่มต้นของ jwt โดยป้อนกุญแจลับ ใส่โค้ดดังกล่าว หลังบรรทัด app.config['SQLALCHEMY_DATABASE_URI'] กุญแจลับดังกล่าว (JWT_SECRET_KEY) ใช้ในการลงลายชื่อรับรองว่า token เป็น token ที่สร้างจาก server ของเรา
app.config['JWT_SECRET_KEY'] = 'fdsjkfjioi2rjshr2345hrsh043j5oij5545'
jwt = JWTManager(app)
Login API
เราจะไปแก้ฟังก์ชัน login ให้สร้าง token และคืนค่ากลับไปเป็น json โดยแก้ฟังก์ชัน login ให้เป็นดังนี้
@app.route('/api/login/', methods=['POST'])
def login():
data = request.get_json()
if not data or 'username' not in data or 'password' not in data:
return jsonify({'error': 'Username and password are required'}), 400
user = User.query.filter_by(username=data['username']).first()
if not user or not user.check_password(data['password']):
return jsonify({'error': 'Invalid username or password'}), 401
// ***** สร้าง token แล้วคืนค่ากลับไป
access_token = create_access_token(identity=user.username)
return jsonify(access_token=access_token)
ให้ทดลองเรียก API ดังกล่าว ดูว่าเห็น token หรือไม่ ถ้าทำงานได้จะเห็นดังรูปด้านล่าง ที่มี token คืนกลับมา
อะไรอยู่ใน token
ให้ลอง copy token ที่ได้ ไปลองกับเว็บ https://www.jwt.io/ เพื่อ decode และตรวจสอบ signature อย่าลืมใส่กุญแจลับ (ห้ามใช้กับกุญแจลับจริงๆ)
การป้องกัน route ใน Flask
เราสามารถใช้ jwt_required ในการป้องกัน route ใน Flask ได้ โดยเพิ่ม decorator อย่างเช่นบรรทัด @jwt_required() ไว้ถัดจาก @app.route ดังด้านล่าง
@app.route('/api/todos/', methods=['GET'])
@jwt_required()
def get_todos():
todos = TodoItem.query.all()
return jsonify([todo.to_dict() for todo in todos])
ให้ทดลองเรียก api ดังกล่าว ควรจะต้องไม่สามารถเรียกได้
ถ้าจะเรียกให้ผ่าน จะต้องเพิ่ม request header โดยเพิ่ม header
- Authorization โดยให้ค่าเป็น Bearer token
โดยแทน token ด้วย access token ที่ได้รับมา
ให้ไปเพิ่ม @jwt_required() decorator ให้กับทุก ๆ api end point (ที่ไม่ใช่ login)
หมายเหตุ ในที่นี้เรายังไม่ได้นำข้อมูลอะไรของผู้ใช้ไปทำอะไรเราเลยไม่ได้มีการแกะข้อมูลมาจาก JWT อย่างไรก็ตาม ถ้าเราต้องการข้อมูล เราจะใช้ฟังก์ชัน get_jwt_identity
จัดการรายการ library และ commit
เราได้ติดตั้งไลบรารีไปหลายตัว เราจะต้องอัพเกรดไฟล์ requirements.txt (ใน backend) เพื่อเก็บรายการไลบรารีเหล่านี้ การจัดเก็บดังกล่าวจะทำให้เราสามารถนำโค้ดไปรันที่อื่นได้ง่าย (รวมถึงการทำ github action เพื่อทดสอบโค้ดส่วน backend ด้วย)
เนื่องจากเราติดตั้งไลบรารีด้วย pip เราสามารถสั่ง
pip freeze
ให้ pip แสดงรายการไลบรารีได้ (ให้ทดลองสั่งดู ให้กดสั่งตอนอยู่ใน virtual environment แล้ว) ดังตัวอย่างด้านล่าง (อาจจะไม่เหมือนกันทั้งหมด)
aptdaemon==2.0.2 asn1crypto==1.5.1 attrs==25.3.0 autocommand==2.2.2 babel==2.17.0 bcc==0.31.0 bcrypt==4.2.0 beautifulsoup4==4.13.4 blinker==1.9.0 .... และอื่น ๆ ....
รายการดังกล่าวจะมีลักษณะเดียวกับรายการใน requirements.txt ดังนั้นเราจะสามารถสั่ง
pip freeze > requirements.txt
เพื่ออัพเดท requirements.txt ได้เลย
เมื่อจัดการเรียบร้อยแล้ว เราจะเก็บงานที่เราทำลงใน git ต่อ
ให้ add file ที่เราแก้ไข (อย่าลืม migrations ใน backend/migrations/versions) จากนั้น commit งานให้เรียบร้อย ให้ใช้ git message ที่มีความหมาย
🄶 commit งานที่ทำ แล้ว push กลับไปที่ github ด้วย
Frontend
เนื่องจาก backend ของเราตอนนี้ ปิด route ที่อ่านข้อมูลด้วย jwt_required หมดแล้ว การรันส่วน react จะขึ้น error จำนวนมาก ไม่ต้องตกใจ
ถ้าไม่อยากเห็น error ที่ alert ขึ้นมา ให้ไป comment ส่วน alert ออก ดังตัวอย่างด้านล่าง
async function fetchTodoList() {
try {
// ... ละไว้
} catch (err) {
// **** comment บรรทัด alert ด้านล่างนี้
// alert("Failed to fetch todo list from backend. Make sure the backend is running.");
}
}
อย่างไรก็ตาม ถ้ารันเทส
npm test
ทุกอย่างจะยังสามารถทำงานได้ตามปกติ ลองหาคำตอบเองดูว่าทำไมการทำ authentication ที่เราเขียนจึงไม่มีผลกับ unit test
แยก TodoList
ก่อนที่เราจะเริ่มทำอะไรซับซ้อน เราจะแยกส่วน TodoList ทั้งหมดออกมาจาก App เพราะว่าในตอนสุดท้ายเราจะนำส่วน authentication ไปครอบทุกอย่างไว้ แต่ถ้างานของเราอยู่ใน App เราจะไปหาอะไรมาครอบ App อีกจะทำไม่ได้
ดังนั้นในขั้นแรกเราจะย้ายทุกอย่างที่เป็นการทำงานเกี่ยวกับ TodoList ออกจาก App ไปสร้างเป็น Component ใหม่ อย่างไรก็ตาม เราจะพยายามเก็บพวก config เช่น url ต่าง ๆ ไว้ใน App
เราจะย้ายโค้ดเดิมจาก App.jsx ไปสร้างเป็น TodoList.jsx ดังตัวอย่างด้านล่าง
หลัก ๆ ที่เราต้องแก้คือเปลี่ยนชื่อฟังก์ชันจาก App เป็น TodoList และใส่ prop apiUrl
import { useState, useEffect } from 'react'
import './App.css'
import TodoItem from './TodoItem.jsx'
function TodoList({apiUrl}) {
const TODOLIST_API_URL = apiUrl;
const [todoList, setTodoList] = useState([]);
const [newTitle, setNewTitle] = useState("");
// ... โค้ดเดิม
}
export default TodoList; // อย่าลืมแก้ export
จากนั้นเราจะทำความสะอาด App.jsx ของเรา โดยนำส่วนที่ย้ายออกไปแล้วทิ้งไป
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from './context/AuthContext.jsx';
import './App.css'
import TodoList from './TodoList.jsx'
function App() {
const TODOLIST_API_URL = 'http://localhost:5000/api/todos/';
return (
<TodoList apiUrl={TODOLIST_API_URL}/>
)
}
export default App
ทดลอง Router
ในส่วนของ React UI, หน้าจอเราจะมีความซับซ้อนขึ้น (หลัก ๆ คือจะมีเหมือนหลายหน้า) เราจะเริ่มต้องการเครื่องมือที่ทำให้เราจัดการหน้าจอได้เหมือนการกด link และ redirection ใน html เครื่องมือมาตรฐานคือ React Router
เราจะติดตั้งโดยสั่ง
npm install react-router-dom
เราจะไปทดลองแนวคิดของ router กันก่อน
คุณอาจจะเห็น message เกี่ยวกับ vulnerabilities จะมีรายละเอียดวิธีการจัดการให้ต่อไป
แนวคิดของ Router คือเราจะครอบ App ทั้งหมดของเราไว้ด้วย BrowserRouter ภายในนั้นเราจะนิยาม Route ต่าง ๆ ที่จะถูกแยกไป render ตาม path ตัวอย่างโครงของการใช้งานจะเป็นดังด้านล่าง
<BrowserRouter>
<Routes>
<Route path="/" element={
สิ่งที่จะถูกแสดงเมื่อ path = /
} />
<Route path="/about" element={
สิ่งที่จะถูกแสดงเมื่อ path = /about
} />
<Route path="/login" element={
สิ่งที่จะถูกแสดงเมื่อ path = /login
} />
<Route path="/logout" element={
สิ่งที่จะถูกแสดงเมื่อ path = /logout
} />
</Routes>
</BrowserRouter>
เราจะทดลองโดยการเพิ่มหน้า about ลงไปใน todo app ของเราสักเล็กน้อย (ไม่มีประโยชน์อะไร)
function App() {
// ... ละส่วนบนไว้ ...
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<TodoList apiUrl={TODOLIST_API_URL}/>
}
/>
<Route
path="/about"
element={
<>
<h1>About</h1>
<p>This is a simple todo list application built with React and Flask.</p>
<a href="/">Back to Home</a>
</>
}
/>
</Routes>
</BrowserRouter>
)
}และจะไปเพิ่มลิงก์ไปยังหน้า About จาก TodoList ในไฟล์ TodoList.jsx component ดังด้านล่าง
function TodoList({apiUrl}) {
// ... ละส่วนบนไว้ ...
return (
<>
.... ละไว้ .... เพิ่มบรรทัดด้านล่าง
<br/>
<a href="/about">About</a>
</>
)
}
เราจะสามารถลองกดไปมาระหว่างหน้าหลัก กับ About ได้
จากโครงของ Router แบบนี้ จะทำให้เราสามารถสร้างส่วนของ Route ที่มีการป้องกันด้วย authentication ได้ไม่ยากนัก
Login form
เราจะเพิ่ม component LoginForm และนำไปเพิ่มเป็นหนึ่งใน Route
เพิ่มไฟล์ frontent/src/LoginForm.jsx ดังด้านล่าง ฟอร์มดังกล่าวจะรับ prop loginUrl สำหรับการ login และขอ access token
โค้ดด้านล่างจะยังไม่ทำงานอะไร โดยเราจะเพิ่มลงไปใน route ให้เสร็จก่อน
import { useState } from 'react'
import './App.css'
function LoginForm({loginUrl}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
async function handleLogin(e) {
e.preventDefault();
}
return (
<form onSubmit={(e) => {handleLogin(e)}}>
Username:
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
<br/>
Password:
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<br/>
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
เพิ่ม component ดังกล่าว ลงใน App ดังนี้
ขั้นแรกเราต้องไป import LoginForm ที่ตอนต้นโค้ดใน App.jsx
import LoginForm from './LoginForm.jsx';
จากนั้นเราไปเพิ่มค่าคงที่ของ URL สำหรับการ login
function App() {
// ...
const TODOLIST_LOGIN_URL = 'http://localhost:5000/api/login/';
// ...
}
จากนั้นให้เพิ่ม Login เข้าไปเป็นอีกหนึ่งใน Route ใน Routes
<Routes>
....
<Route
path="/login"
element={
<LoginForm loginUrl={TODOLIST_LOGIN_URL} />
}
/>
</Routes>
ให้ลอง refresh ที่หน้าหลัก จากนั้นลองไปที่ url /login ดูว่าฟอร์ม login ปรากฏขึ้นหรือไม่
เราจะไปเพิ่มลิงก์สำหรับ Login ไว้ที่หน้า TodoList ด้วยเพื่อความสะดวกในการทดสอบ โดยในไฟล์ TodoList.jsx ให้เพิ่มบรรทัดด้านล่างลงไปใน (ต่อจากลิงก์ไป About)
function TodoList({apiUrl}) {
// ...
return (
<>
....
<a href="/about">About</a>
<!--- **** เพิ่มบรรทัดด้านล่าง **** --->
<br/>
<a href="/login">Login</a>
</>
)
}
ถ้าเราสามารถทำให้ Login form แสดงได้อย่างถูกต้องแล้ว ให้เพิ่มโค้ดสำหรับการเรียก API โดยแก้ฟังก์ชัน handleLogin ใน LoginForm.jsx ให้เป็นดังด้านล่าง
async function handleLogin(e) {
e.preventDefault();
try {
const response = await fetch(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: username, password: password }),
});
if (response.ok) {
const data = await response.json();
console.log(data);
alert("Login successful. access token = " + data.access_token);
} else if (response.status === 401) {
alert("Login failed: Invalid username or password");
}
} catch (error) {
console.log("Error logging in:", error);
}
}
ให้ลองกด login ทั้งที่รหัสผ่านถูกต้องและผิดพลาด เพื่อทดสอบว่าทำงานได้ถูกต้อง
เราอาจจะไม่ชอบเห็น alert เยอะๆ ยิ่งโดยเฉพาะตอนที่เราพิมพ์รหัสผ่านผิด เราจะปรับโค้ดให้มีการแสดงเป็นข้อความแทน ใน React สามารถทำได้โดยสะดวก
เพิ่ม state errorMessage ในตอนต้น LoginForm
const [errorMessage, setErrorMessage] = useState("");
แก้โค้ด แทนที่จะ alert ให้ปรับ state ของตัวแปร errorMessage แทน
if (response.ok) {
//...
} else if (response.status === 401) {
setErrorMessage("Invalid username or password");
}
แก้ในส่วน jsx html สำหรับแสดงผล ให้เพิ่มการแสดงผล errorMessage แบบมีเงื่อนไขดังด้านล่าง
<form onSubmit={(e) => {handleLogin(e)}}>
<!--- **** เพิ่มบรรทัดด้านล่าง ****** --->
{errorMessage && <p>{errorMessage}</p>}
...
</form>
ให้ทดลองเพื่อตรวจสอบความถูกต้อง
เมื่อเราได้ access token มาแล้ว เราจะต้องหาวิธีส่ง token ดังกล่าวให้กับส่วนอื่น ๆ ของ App ซึ่งถ้าเราใช้การส่งค่าผ่านทาง props มันจะเป็นการส่งที่กระจายไปทั่วโค้ดมาก ๆ ดังนั้นใน React จึงมีแนวคิดที่เรียกว่า Context ที่เราสามารถใช้เพื่อส่งข้อมูลที่มีลักษณะเป็นแบบ global ได้
เราจะใช้ context ในการเก็บข้อมูลเกี่ยวกับ access token (และอาจจะรวมไปถึงข้อมูลอื่น ๆ ที่เกี่ยวข้องกับผู้ใช้) จากนั้นเราจะจัดการทำส่วน Router ให้ซ่อน Route ที่เข้าถึงไม่ได้โดยอัตโนมัติด้วยข้อมูลใน context นี้
Context
แนวคิดของ context คือเหมือนการสร้าง global state โดยมี Provider เป็นตัวจัดการ และ component ใดที่อยู่ภายใต้ Provider นั้นจะสามารถเรียก global state เหล่านั้นได้ โดยเรียกลักษณะเดียวกับการเรียก useState
ให้ไปสร้างไฟล์ frontent/src/context/AuthContext.jsx ดังด้านล่าง (อย่าลืมสร้าง directory context ก่อน)
import React, { createContext, useState, useContext } from "react";
export const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [username, setUsername] = useState(localStorage.getItem("username") || null);
const [accessToken, setAccessToken] = useState(localStorage.getItem("accessToken") || null);
const login = (username, token) => {
localStorage.setItem("username", username);
localStorage.setItem("accessToken", token);
setAccessToken(token);
setUsername(username);
};
const logout = () => {
localStorage.removeItem("username");
localStorage.removeItem("accessToken");
setAccessToken(null);
setUsername(null);
};
return (
<AuthContext.Provider value={{ username, accessToken, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
โค้ดดังกล่าวแบ่งได้เป็นหลายส่วน เราจะสรุปรายละเอียดให้หลังจากที่เราเห็นการนำไปใช้แล้ว แต่สิ่งที่เราจะได้จากโค้ดนี้คือ component AuthProvider และฟังก์ชัน useAuth ที่เราจะนำไปใช้ดังนี้
เราจะครอบ component ทั้งหมดใน component App ด้วย AuthProvider โดยแก้ไฟล์ App.jsx ดังนี้
// *** เพิ่มการ import
import { AuthProvider } from './context/AuthContext.jsx';
function App() {
// ...
return (
<AuthProvider>
<!--- **** ย้าย BrowserRouter มาไว้ด้านใน AuthProvider **** --->
<BrowserRouter>
<Routes>
<Route .... />
<Route .... />
</Routes>
</BrowserRouter>
</AuthProvider>
)
}
เมื่อทำเช่นนี้แล้วทุก ๆ component ที่ถูกครอบด้วย AuthProvider จะสามารถเรียก useAuth ได้ รวมทั้ง TodoList และ LoginForm
LoginForm
เราจะเรียกใช้ useAuth เพื่อให้เราสามารถเรียกฟังก์ชัน login เพื่อเก็บ username และ token ลง context ได้ใน LoginForm ให้แก้ดังนี้
// *** เพิ่มบรรทัด import
import { useAuth } from "./context/AuthContext";
function LoginForm({loginUrl}) {
// เรียก useAuth
const { login } = useAuth();
async function handleLogin(e) {
e.preventDefault();
try {
// ...
if (response.ok) {
const data = await response.json();
console.log(data);
alert("Login successful. access token = " + data.access_token);
// เรียกฟังก์ชัน login เพื่อเก็บ username และ token
login(username, data.access_token);
} // ... else /
}
}
// ...
}
เมื่อแก้แล้ว ให้ทดลอง login ดู ถ้ามี alert แสดง token แปลว่าสามารถ login ได้ แต่เราก็ยังดูยากอยู่ดีว่าได้มีการเก็บ username และ token ไปแล้ว
เราจะเพิ่มโค้ดอีกสักเล็กน้อย เพื่อทดสอบ และทำให้เราเห็นผลลัพธ์ชัดขึ้น เราจะนำ username ออกมาจาก useAuth ด้วย
อย่างไรก็ตาม โค้ด LoginForm ของเรามี const username สำหรับใช้ใน form อยู่แล้ว ถ้าเราเขียน
const { login, username } = useAuth();
จะ error เพราะว่าชื่อ constant ชนกัน
เราจะต้อง destructure พร้อมเปลี่ยนชื่อ และนำค่าไปแสดงผล โดยแก้ไฟล์ LoginForm.jsx ให้เป็นดังนี้
function LoginForm({loginUrl}) {
// เปลึ่ยนชื่อจาก username ให้เป็น loggedInUsername จาก useAuth
const { login, username: loggedInUsername } = useAuth();
// ...
return (
<form onSubmit={(e) => {handleLogin(e)}}>
....
<button type="submit">Login</button>
<!--- *** เพิ่มบรรทัดด้านล่าง **** --->
{loggedInUsername && <p>User {loggedInUsername} is already logged in.</p>}
</form>
);
}
เมื่อแก้เสร็จแล้ว ถ้าเรา login ได้เรียบร้อย จะมีข้อความแสดง is already logged in ด้านล่าง
TodoList
ในส่วนของ TodoList ก็จะมีการจัดการคล้ายกัน เราจะดำเนินการดังนี้
- อ่าน username, accessToken, และ logout มาจาก useAuth
- เมื่อเรียก fetch จะนำ accessToken ไปส่งให้ backend
- แสดง username
- ถ้ามี username ให้แสดงปุ่ม Logout
โค้ด TodoList.jsx จะแก้เป็นดังนี้ (พร้อมคำอธิบาย)
// **** เพิ่ม import
import { useAuth } from './context/AuthContext.jsx';
import './App.css'
function TodoList({apiUrl}) {
// ** อ่านค่าจาก context
const { username, accessToken, logout } = useAuth();
// ...
async function fetchTodoList() {
try {
// **** ในการเรียก fetch เราจะส่ง accessToken ไปทาง http header Authorization
const response = await fetch(TODOLIST_API_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
// ....
} catch (err) {
//alert("Failed to fetch todo list from backend. Make sure the backend is running.");
}
}
// ....
return (
<>
....
<br/>
<a href="/about">About</a>
<br/>
{username && (
<a href="#" onClick={(e) => {e.preventDefault(); logout();}}>Logout</a>
)}
</>
)
}
ในการทดลองโค้ดนี้ ให้ไป LoginForm ก่อน (พิมพ์ url /login) แล้วเมื่อ login เรียบร้อย ให้พิมพ์ url ของหน้าหลัก เพื่อดูรายการ จะเห็น TodoList ปรากฏ พร้อมด้วยชื่อ username
ถ้าเรากด Logout จะพบว่า username หายไป แต่ว่า TodoList ยังค้างอยู่ เพราะว่าเราไม่ได้ไปปรับ state นอกจากนี้ แม้เราจะไปบังคับให้ fetchTodoList ใหม่ แต่เรา try catch เอาไว้แล้ว ทำให้ค่าในรายการเดิมค้างอยู่ด้วย เราจะแก้ไขโดย
- ไปแก้ useEffect โดยระบุด้วยว่าถ้า username เปลี่ยนให้เรียก fetchTodoList (จะมีผลตอนหลัง logout)
- ปรับ try-catch ใน fetchTodoList ถ้าเกิด error ให้เคลียร์รายการ Todo
function TodoList({apiUrl}) {
// ...
useEffect(() => {
fetchTodoList();
}, [username]); // *** เพิ่ม username ในรายการ
async function fetchTodoList() {
try {
// ...
} catch (err) {
//alert("Failed to fetch todo list from backend. Make sure the backend is running.");
setTodoList([]); // *** เคลียร์รายการ
}
}
}
อย่างไรก็ตาม app ของเรายังไม่มีการปิดกั้นการเรียก route ก่อนจะ login เลย เราจะจัดการในส่วนต่อไป
🄶 ก่อนจะทำต่อ ให้ commit งานที่ทำ อย่าลืมเพิ่มไฟล์ src/TodoList.jsx และ src/context/AuthContext.jsx ด้วย
แล้ว context คืออะไร?
- จะมาเขียนเพิ่มเติม ด้านล่างเป็นคำตอบจาก Claude
Context และ Provider คืออะไร?
- AuthContext คือ "ช่องทาง" หรือ "สาย" ที่ใช้ส่งข้อมูล — มันไม่ได้เก็บข้อมูลเองหรือทำอะไรมากนัก แค่เป็นตัวกลางที่ React ใช้รู้ว่าข้อมูลชุดนี้คือชุดเดียวกัน
- AuthProvider คือ component ที่ เป็นเจ้าของ state (user, accessToken) และ กระจายค่า เหล่านั้นออกไปให้ทุก component ที่อยู่ข้างใน
ถ้าจะเปรียบเทียบให้เห็นภาพ ให้ลองนึกถึง ระบบไฟฟ้าในบ้าน:
- AuthContext คือ สายไฟ — เป็นตัวกลางส่งกระแส
- AuthProvider คือ แหล่งจ่ายไฟ — เป็นคนผลิตและจ่ายกระแสไฟ
- useAuth() คือ ปลั๊กไฟ — component ไหนอยากใช้ก็เสียบได้เลย
PrivateRoute
เราจะสร้าง component PrivateRoute ในไฟล์ src/PrivateRoute.jsx เพื่อตรวจสอบว่าใน AuthContext มี accessToken หรือไม่ ถ้าไม่มีจะย้ายหน้าไป /login โดยอัตโนมัติ
import { Navigate } from "react-router-dom";
import { useAuth } from "./context/AuthContext";
export default function PrivateRoute({ children }) {
const { accessToken } = useAuth();
if (accessToken)
return children;
else
return (
<Navigate to="/login" replace />
);
}
บรรทัดเงื่อนไข if บางคนอาจจะเขียนให้สั้นกว่านี้ได้เป็น
return (accessToken ? children : <Navigate to="/login" replace />);
เราจะนำ component นี้ไปแทรกไว้ใน App ก่อนจะถึง TodoList ดังนี้
// ** เพิ่มการ import
import PrivateRoute from "./PrivateRoute.jsx";
function App() {
// ...
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<PrivateRoute> <!--- *** นำมาครอบ TodoList ไว้ *** --->
<TodoList apiUrl={TODOLIST_API_URL}/>
</PrivateRoute>
}
/>
...
</BrowserRouter>
</AuthProvider>
)
}
ให้ลอง logout ดูแล้ว refresh ที่หน้า / จะพบว่าเว็บ redirect ไปหาหน้า login โดยอัตโนมัติ
เก็บกวาดงาน
ตอนนี้สิ่งที่เราต้องทำคือการทำให้ redirect ไปหน้า TodoList หลัง login เสร็จ และการเพิ่ม authorization header ลงไปในทุก ๆ request
เราจะ redirect โดยใช้ navigate จาก react-router-dom โดยดำเนินการแก้ LoginForm.jsx ดังนี้
// *** เพิ่ม import
import { useNavigate } from 'react-router-dom'
function LoginForm({loginUrl}) {
// *** useNavigate เพื่อรับฟังก์ชันไว้ย้าย url
const navigate = useNavigate();
async function handleLogin(e) {
// ...
try {
// ...
if (response.ok) {
const data = await response.json();
console.log(data);
//alert("Login successful. access token = " + data.access_token);
login(username, data.access_token);
// *** หลังจาก login และเก็บ username กับ token แล้ว เพิ่มบรรทัดนี้
navigate('/');
} // ....
} // ...
}
// ...
}
เพิ่ม Authorization header
เพื่อให้เราสามารถเรียกทุก api ได้แบบเดิม เราจะต้องไปเพิ่ม Authorization header ให้กับทุก ๆ การเรียก fetch ในฟังก์ชัน toggleDone, addNewTodo, deleteTodo และ addNewComment
มีวิธีการที่สะดวกกว่านี้ แต่เราจะขอละไว้ก่อน ตอนนี้ให้ไปปรับแก้ และทดลองว่าแอฟสามารถทำงานได้
🄶 อย่าลืม commit งาน อย่าลืมเพิ่ม src/PrivateRoute.jsx เมื่อทำเสร็จแล้ว ให้ push กลับไปที่ github ด้วย
แก้ Unit test
จากที่ตอนเริ่ม Unit test ต่าง ๆ ของเรายังสามารถทำงานได้ หลังจากแก้ไปมา เพิ่ม authentication ตอนนี้ไม่สามารถทำงานได้แล้ว เราจะมาแก้กัน
ขั้นแรก เนื่องจากเรา refactor component TodoList ออกมาจาก App เราจะแก้ unit test ของ App ให้เป็นของ TodoList โดยดำเนินการดังนี้
- เปลี่ยนชื่อไฟล์ frontend/src/__test__/App.test.jsx เป็น frontend/src/__test__/TodoList.test.jsx
- แก้ component
Appเป็นTodoListให้หมด ใน test ดังกล่าว
ให้ทดลองรัน npm test ดูผล จะได้ผลสรุปดังด้านล่าง
Test Files 1 failed | 1 passed (2)
Tests 2 failed | 6 passed (8)
Start at 02:41:36
Duration 1.72s (transform 133ms, setup 323ms, import 210ms, tests 439ms, environment 1.55s)
ถ้าไปดูรายละเอียดจะพบ error เกี่ยวกับ useAuth
TypeError: Cannot destructure property 'username' of '(0 , __vite_ssr_import_2__.useAuth)(...)' as it is null.
❯ TodoList src/TodoList.jsx:12:11
10| const [todoList, setTodoList] = useState([]);
11| const [newTitle, setNewTitle] = useState("");
12| const { username, accessToken, logout } = useAuth();
| ^
13|
และนี่คือประเด็นหลักที่เราแก้จากของเดิม
เนื่องจาก useAuth เป็นฟังก์ชันภายนอก เราจะต้อง mock ฟังก์ชันนี้เพื่อทำให้เราสามารถเทสได้ อย่างไรก็ตามฟังก์ชันดังกล่าวเกี่ยวกับ context และมีความซับซ้อน ทำให้การ mock ของเราจะยุ่งยากเล็กน้อย ให้ทำตามตัวอย่างด้านล่างได้เลย
ขั้นแรก เราจะต้องไป mock ไฟล์ ../context/AuthContext เพื่อให้คืนฟังก์ชัน useAuth ที่เราเตรียมไว้ และ import ฟังก์ชันนั้นมา ให้เพิ่มบรรทัดด้านล่าง ก่อนถึงบรรทัด describe('TodoList')
vi.mock('../context/AuthContext', () => ({
useAuth: vi.fn(),
}));
import { useAuth } from '../context/AuthContext';
จากนั้นใน beforeEach เราจะสั่งให้ถ้ามีการเรียกใช้ให้คืนค่าที่เราระบุ ดังด้านล่าง
describe('TodoList', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
useAuth.mockReturnValue({
username: 'testuser',
login: vi.fn(),
logout: vi.fn(),
});
});
// ...
}
เมื่อเรา mock ฟังก์ชัน useAuth ได้แล้ว โค้ดสำหรับทดสอบที่เหลือก็จะสามารถทำงานได้ตามเดิม
