01204223/authentication

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
ส่วนหนึ่งของวิชา 01204223

ในโครงงานของเรา เราจะใช้การทำ authentication ด้วย Token อย่างไรก็ตาม ยังมีวิธีที่ทำได้อีกหลายวิธี ด้านล่างเป็นความรู้พื้นฐานที่อ่านประกอบได้

แนวทางการทำ Authentication สำหรับ Web Application

เขียนโดย Gemini

การยืนยันตัวตน (Authentication) เป็นหัวใจสำคัญของความปลอดภัยใน Web Application บทความนี้จะอธิบายและเปรียบเทียบระหว่างสองแนวทางหลักที่นิยมใช้ในปัจจุบัน คือ Session-based และ Token-based

1. Session-based Authentication

เป็นวิธีการแบบดั้งเดิม (Traditional) ที่เน้นการเก็บสถานะการล็อกอินไว้ที่ฝั่ง Server (Stateful)

กระบวนการทำงาน (Workflow)

  1. User Login: ผู้ใช้ส่ง Username/Password จาก Frontend ไปยัง Backend
  2. Create Session: เมื่อตรวจสอบข้อมูลถูกต้อง Backend จะสร้าง "Session ID" และบันทึกข้อมูลผู้ใช้ (เช่น User ID, Role) ลงในหน่วยความจำของ Server หรือ Database
  3. Set Cookie: Backend ส่ง Session ID กลับมาหา Frontend ผ่านทาง Set-Cookie header (มักจะตั้งค่าเป็น HttpOnly เพื่อความปลอดภัย)
  4. Authenticated Request: ในทุกๆ Request ถัดไป Browser จะแนบ Cookie ที่มี Session ID ไปให้ Server โดยอัตโนมัติ
  5. Validation: Server ตรวจสอบ Session ID จาก Cookie เทียบกับข้อมูลใน Storage หากตรงกันถือว่ายืนยันตัวตนสำเร็จ

ข้อดีและข้อเสีย

ข้อดี ข้อเสีย
  • Security: ควบคุมได้ง่าย (เช่น สั่ง Invalidate session เพื่อเตะผู้ใช้ออกได้ทันที)
  • Code Simplicity: Framework ส่วนใหญ่รองรับมาแต่กำเนิด
  • Cookie Handling: Browser จัดการการส่ง Cookie ให้เองโดยอัตโนมัติ
  • Scalability: หากมี Server หลายตัว (Load Balancing) ต้องทำ Sticky Session หรือใช้ Redis เพื่อแชร์ Session Store ร่วมกัน
  • Performance: เปลืองทรัพยากร Server ในการเก็บข้อมูล Session ของผู้ใช้ทุกคน
  • CSRF: มีความเสี่ยงต่อการโจมตีแบบ CSRF (Cross-Site Request Forgery) หากไม่ป้องกันให้ดี

2. Token-based Authentication (JWT)

เป็นวิธีการที่นิยมใน Modern Web App และ Microservices โดยเน้นการไม่เก็บสถานะที่ Server (Stateless) แต่ใช้การเข้ารหัสข้อมูลยืนยันตัวตนส่งไปให้ Client ถือไว้แทน (นิยมใช้มาตรฐาน JSON Web Token - JWT)

กระบวนการทำงาน (Workflow)

  1. User Login: ผู้ใช้ส่ง Username/Password ไปยัง Backend
  2. Generate Token: Backend ตรวจสอบข้อมูล หากถูกต้องจะสร้าง Token (String ยาวๆ ที่ผ่านการเข้ารหัสและลงลายเซ็น Digital Signature) ซึ่งบรรจุข้อมูลผู้ใช้ (Payload) ไว้ข้างใน
  3. Send Token: Backend ส่ง Token กลับไปให้ Frontend (ปกติส่งเป็น JSON response)
  4. Store Token: Frontend ต้องเขียนโค้ดเพื่อเก็บ Token เอง (เช่น เก็บใน LocalStorage หรือ Cookie)
  5. Authenticated Request: Frontend ต้องแนบ Token ไปใน HTTP Header (มักใช้ header: Authorization: Bearer <token>)
  6. Validation: Server ตรวจสอบความถูกต้องของลายเซ็น (Signature) ใน Token โดยใช้ Secret Key หากถอดรหัสได้และ Token ยังไม่หมดอายุ ก็จะอนุญาตให้เข้าถึงข้อมูล (ไม่ต้อง Query Database เพื่อหา Session)

ข้อดีและข้อเสีย

ข้อดี ข้อเสีย
  • Stateless & Scalable: Server ไม่ต้องจำอะไร ขยาย Server (Scale out) ได้ง่ายมาก
  • Cross-Domain: สามารถใช้ Token เดียวกันกับหลายๆ Domain หรือ Mobile App ได้ง่าย
  • Performance: ลดภาระการอ่านเขียน Database/Memory เพื่อตรวจสอบ Session
  • Revocation Difficulties: การยกเลิก Token ก่อนหมดอายุทำได้ยาก (เพราะ Server ไม่ได้เก็บสถานะ) ต้องใช้เทคนิค Blacklist หรือลดอายุ Token ให้สั้นลง (Short-lived)
  • Storage Security: หากเก็บใน LocalStorage อาจเสี่ยงต่อ XSS
  • Payload Size: Token มีขนาดใหญ่กว่า Session ID อาจเปลือง Bandwidth หากข้อมูลเยอะ

ตารางเปรียบเทียบ (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>
              &nbsp;|&nbsp;
              <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> 
      )}
    </>
  )
}

Protected routes