ผลต่างระหว่างรุ่นของ "01204223/authentication"
Jittat (คุย | มีส่วนร่วม) |
Jittat (คุย | มีส่วนร่วม) |
||
| แถว 812: | แถว 812: | ||
}} | }} | ||
| − | === | + | === PrivateRoute === |
| + | เราจะสร้าง component PrivateRoute ในไฟล์ 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"> | ||
| + | function App() { | ||
| + | // ... | ||
| + | |||
| + | return ( | ||
| + | <AuthProvider> | ||
| + | <BrowserRouter> | ||
| + | <Routes> | ||
| + | <Route | ||
| + | path="/" | ||
| + | element={ | ||
| + | <PrivateRoute> <!--- *** นำมาครอบ TodoList ไว้ *** ---> | ||
| + | <TodoList apiUrl={TODOLIST_API_URL}/> | ||
| + | </PrivateRoute> | ||
| + | } | ||
| + | /> | ||
| + | ... | ||
| + | </BrowserRouter> | ||
| + | </AuthProvider> | ||
| + | ) | ||
| + | } | ||
| + | </syntaxhighlight> | ||
| + | |||
| + | ให้ลอง logout ดูแล้ว refresh ที่หน้า / จะพบว่าเว็บ redirect ไปหาหน้า login โดยอัตโนมัติ | ||
รุ่นแก้ไขเมื่อ 05:42, 20 กุมภาพันธ์ 2569
- ส่วนหนึ่งของวิชา 01204223
ในโครงงานของเรา เราจะใช้การทำ 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 ด้วย Bycrypt
เมื่อ 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
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 ในรูปแบบต่าง ๆ ดู ก่อนจะแก้ต่อไป
เราจะเพิ่มโค้ดในการสร้าง 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
เราจะเริ่มโดยการตรวจสอบรหัสผ่าน
@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'})
Token
เราจะใช้ไลบรารี Flask-JWT-Extended ในการสร้างและตรวจสอบ JSON Web Token (JWT) โดยติดตั้งดังนี้
pip install flask-jwt-extended
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
from flask_jwt_extended import JWTManager
app.config['JWT_SECRET_KEY'] = 'fdsjkfjioi2rjshr2345hrsh043j5oij5545'
jwt = JWTManager(app)
Login API
@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
access_token = create_access_token(identity=user.username)
return jsonify(access_token=access_token)
อะไรอยู่ใน token
การป้องกัน route ใน Flask
@app.route('/api/todos/', methods=['GET'])
@jwt_required()
def get_todos():
todos = TodoItem.query.all()
return jsonify([todo.to_dict() for todo in todos])
ในที่นี้เรายังไม่ได้นำข้อมูลอะไรของผู้ใช้ไปทำอะไรเราเลยไม่ได้มีการแกะข้อมูลมาจาก 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
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;
import LoginForm from './LoginForm.jsx';
function App() {
// ...
const TODOLIST_LOGIN_URL = 'http://localhost:5000/api/login/';
// ...
}
<a href="/about">About</a>
|
<a href="/login">Login</a>
<Route
path="/login"
element={
<LoginForm loginUrl={TODOLIST_LOGIN_URL} />
}
/>
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);
}
}
เราอาจจะไม่ชอบเห็น alert เยอะๆ ยิ่งโดยเฉพาะตอนที่เราพิมพ์รหัสผ่านผิด
const [errorMessage, setErrorMessage] = useState("");
if (response.ok) {
//...
} else if (response.status === 401) {
setErrorMessage("Invalid username or password");
}
<form onSubmit={(e) => {login(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 ด้วย
PrivateRoute
เราจะสร้าง component PrivateRoute ในไฟล์ 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 ดังนี้
function App() {
// ...
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<PrivateRoute> <!--- *** นำมาครอบ TodoList ไว้ *** --->
<TodoList apiUrl={TODOLIST_API_URL}/>
</PrivateRoute>
}
/>
...
</BrowserRouter>
</AuthProvider>
)
}
ให้ลอง logout ดูแล้ว refresh ที่หน้า / จะพบว่าเว็บ redirect ไปหาหน้า login โดยอัตโนมัติ