ในสัปดาห์นี้เราจะพักจากการเขียนโค้ดฝั่งหน้าบ้าน Flutter ชั่วคราว เพื่อร่วมกันวางโครงร่าง REST API Backend ดึงข้อมูลทริปเดินทางจำลอง และออกแบบระบบรักษาความปลอดภัยแบบไร้สถานะ (Stateless Authentication) โดยใช้ระบบ Token ร่วมกับ Django REST Framework (DRF).
ระบบยืนยันตัวตนที่เราจะพัฒนาสอดคล้องกับแนวปฏิบัติด้านความปลอดภัยของ OWASP และสถาปัตยกรรมระดับ Production
เซิร์ฟเวอร์จะสร้างเซสชันเก็บไว้ในหน่วยความจำ/ฐานข้อมูล และส่ง Session ID ไปกักเก็บไว้ในเบราว์เซอร์ผ่านคุกกี้.
เซิร์ฟเวอร์ตรวจสอบบัญชีสำเร็จแล้วจะส่ง JSON Web Token (JWT) กลับไปให้ไคลเอนต์เก็บไว้ และเซิร์ฟเวอร์จะไม่เก็บสถานะล็อกอินใดๆ ไว้ในฝั่งหลังบ้านเลย.
💡 คำแนะนำเชิงระบบ: "อะไรสเกลได้ดีกว่าในระยะยาว?" นี่คือโจทย์ที่แยกสถาปัตยกรรมระดับโปรดักชันออกจากโปรเจกต์ระดับเริ่มต้น. การใช้ Token ช่วยแก้ปัญหานี้ให้แอปพลิเคชันอย่าง Compass App.
ในสภาพแวดล้อมสถาปัตยกรรมระดับ Production การสื่อสารระบบจะเดินทางแบบทิศทางเดียว (Unidirectional Flow) และการแกะข้อมูลเพื่อสกัดบัญชีทำงานจะเป็นไปตามลำดับชั้นอย่างเป็นระเบียบ:
Authorization: Bearer <token>
ก่อนเริ่มต้นเขียนระบบฐานข้อมูลหลังบ้าน เราจะใช้ uv ตัวจัดการแพ็กเกจ Python ความเร็วสูงยุคใหม่ ที่ทำงานแทนคู่หู pip + venv ได้ครบวงจร (จัดการ interpreter, dependencies และ lockfile ให้อัตโนมัติ):
# 1. ติดตั้ง uv (macOS/Linux)
$ curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell): irm https://astral.sh/uv/install.ps1 | iex
# 2. สร้างโปรเจกต์และติดตั้ง dependencies (uv สร้าง .venv + uv.lock ให้อัตโนมัติ)
$ uv init wk12-backend && cd wk12-backend
$ uv add django djangorestframework djangorestframework-simplejwt django-cors-headers django-oidc-provider
# 3. สร้างโปรเจกต์ Django และรันเซิร์ฟเวอร์ (ไม่ต้อง activate venv เอง)
$ uv run django-admin startproject config .
$ uv run manage.py runserver
Serializers มีบทบาทสำคัญในการเชื่อมโครงสร้างข้อมูลฝั่ง Backend และแอป Flutter โดยจะทำหน้าที่แปลงอ็อบเจกต์ฐานข้อมูล (Models) เป็นข้อความดิบชนิด JSON และในทางกลับกันก็ช่วยตรวจสอบความปลอดภัยข้อมูลที่ไคลเอนต์ยิงขึ้นมาจัดเก็บด้วย:
# app/serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Booking
class BookingSerializer(serializers.ModelSerializer):
class Meta:
model = Booking
fields = ['id', 'destination_name', 'start_date', 'end_date', 'price']
# custom serializer สำหรับล็อกอินเพื่อส่งกลับ Custom Token Claims
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
# เพิ่ม Custom Claim ลงใน Token
token['name'] = user.username
return token
ในการเชื่อมระบบยืนยันตัวตน simple_jwt เราต้องประกาศเส้นทางการออก Token คู่สิทธิ์ล็อกอินและจุดเชื่อมต่อขออัปเดตสิทธิ์ (Token Refresh) ลงในระบบ URL Routing ของแอปหลังบ้าน:
# config/urls.py
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/', include('app.urls')),
]
เขียนโค้ดเพื่อคุ้มครองช่องทางเรียกดูข้อมูลด้วยสิทธิ์ IsAuthenticated เพื่อสกัดกั้นผู้ใช้งานภายนอกที่ไม่ได้ผ่านกระบวนการตรวจสอบสิทธิ์:
# app/views.py (Protected API)
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
class BookingListView(APIView):
permission_classes = (IsAuthenticated, )
def get(self, request):
content = {
'bookings': [{'id': 1, 'destination_name': 'Tokyo', 'price': 35000.0}]
}
return Response(content)
# 🧪 ติดตั้ง HTTPie ครั้งแรกด้วย uv (เครื่องมือทดสอบ API ที่ใช้ง่ายกว่า curl)
$ uv tool install httpie
# 🧪 ขอรับคู่ Token — httpie แปลง key=value เป็น JSON ให้อัตโนมัติ
$ http POST :8000/api/token/ username=student password=password123
# 🧪 ดึงข้อมูลทริปการท่องเที่ยวโดยแนบ Access Token ใน Header
$ http :8000/api/bookings/ "Authorization: Bearer <access_token>"
ตามมาตรฐาน RFC 7519 ตัวโครงสร้างของ JSON Web Token (JWT) จะเป็นสายข้อความกะทัดรัดและปลอดภัยในการส่งผ่าน URL (URL-safe string) ประกอบขึ้นจากการแปลงข้อมูลแบบ Base64url จำนวน 3 ส่วนหลักคั่นด้วยเครื่องหมายจุด (.) :
Header (ส่วนหัว): บอกประเภทของ Token และอัลกอริทึมที่ใช้เข้าลายเซ็นคริปโทกราฟี (เช่น HS256 หรือ RS256) .
Payload (ส่วนข้อมูล): บรรจุชุดข้อมูลเรียกว่า Claims เกี่ยวกับผู้ใช้งานที่ระบุตัวตนและวันเวลาหมดอายุสิทธิ์ .
Signature (ส่วนลายเซ็น): ใช้สิทธิ์ตรวจสอบข้อมูลดิบฝั่งหลังบ้านเพื่อป้องกันภัยคุกคามจากการแก้ไขตัวเลขClaimsระหว่างทาง .
มาตรฐาน **RFC 7519** ได้กำหนดชุดข้อมูลระบุตัวตนและอายุการใช้งานของ Token ที่เป็นสากลและเป็นทางเลือก (Optional แต่แนะนำสำหรับระบบส่งต่อสิทธิ์ร่วมกัน) ไว้ดังนี้ :
`iss` (Issuer): ระบุเซิร์ฟเวอร์ผู้ทำการออกสิทธิ์ Token นี้
`sub` (Subject): ข้อมูลประจำตัวผู้ใช้งานเพื่อตรวจสอบยืนยันบัญชี (เช่น User ID)
`aud` (Audience): ระบุกลุ่มผู้รับหรือเครื่องปลายทางที่ Token นี้ตั้งเป้าหมายไว้
`exp` (Expiration Time): วันหมดอายุของรหัส Token ห้ามใช้ประมวลผลหลังเวลานี้
`iat` (Issued At): ระบุเวลาที่จัดสร้างและออกสิทธิ์ Token นี้ขึ้นมา
`jti` (JWT ID): รหัสจำเพาะสุ่ม (Unique ID) สำหรับสกัดการโจมตีนำสิทธิ์เก่ามารันซ้ำ
นอกเหนือจาก Claims มาตรฐานแล้ว นักศึกษาสามารถออกแบบเสริมคุณลักษณะเฉพาะงาน (Custom Claims) เช่น ชื่อผู้ใช้ (name) หรือสิทธิ์หน้าที่เข้าถึง (role) ลงในก้อน Payload ได้โดยตรงเพื่อใช้กรองสิทธิ์แสดงหน้าจอฝั่ง Flutter ได้ทันทีโดยไม่ต้องยิงดึงข้อมูลจาก API ซ้ำซ้อน .
| คุณลักษณะ | HS256 (Symmetric) | RS256 (Asymmetric) |
|---|---|---|
| กุญแจที่ใช้ | ใช้กุญแจลับร่วมกันแชร์คีย์เดี่ยว (Symmetric Shared Secret) | กุญแจคู่: ส่วนตัวสำหรับออกสิทธิ์ (Private) และส่วนตัวสาธารณะใช้แกะคีย์ (Public) |
| ระดับความปลอดภัย | เสี่ยงต่อการรั่วไหลสูง หากเซิร์ฟเวอร์อื่นโดนเข้าถึงรหัสลับ | ปลอดภัยสูงมาก: ปลายทางผู้ตรวจสอบตรวจสอบผ่านค่า Public JWK เท่านั้น |
| การแชร์คีย์ | ต้องส่งมอบคีย์ความลับผ่านช่องทางเชื่อมโยงเสี่ยงดักจับ | เผยแพร่กุญแจยืนยันด้วยความเสถียรผ่านทางพอร์ตเว็บบอร์ด /.well-known/jwks.json |
เมื่อติดตั้งคลังโปรแกรมสำเร็จแล้ว ให้นำคลาสตรวจสอบยืนยันสิทธิ์ JWTAuthentication ไปกำหนดเข้าสู่ระบบตรวจสอบสิทธิ์กลางของโปรเจกต์ Django REST Framework ในไฟลตั้งค่าหลัก :
# config/settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
}
🎯 หลักการทำงาน: ระบบ DRF จะประมวลผลคำขอขาเข้าจากแอป Flutter โดยอัตโนมัติ โดยการถอดลายเซ็นตรวจสอบสิทธิ์ใน HTTP Authorization header หากระบบแกะลายเซ็นและถอดคีย์ JWT สำเร็จ จะผูกอ็อบเจกต์บัญชีผู้ใช้เข้ากับตัวแปร request.user ให้กับ API View ทันที .
การเชื่อมต่อสิทธิ์ความปลอดภัยในระบบ Simple JWT คอนฟิกสำเร็จรูปผ่านวิวยิงสำเร็จ ได้แก่ TokenObtainPairView สำหรับใช้เป็นด่านแรกในการล็อกอิน และ TokenRefreshView สำหรับใช้เรียกขอรับคู่สิทธิ์เข้าทำงานชุดใหม่ :
# config/urls.py
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
# เส้นทางล็อกอินเพื่อขอรับ Access + Refresh Token
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
# เส้นทางสำหรับยิงเพื่อขอรับ Access Token รอบสิทธิ์อัปเกรดใหม่
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
หากต้องการเขียนโปรแกรมดึงข้อมูลเพื่อฝังคุณสมบัติลงสู่ก้อน Token ให้สร้างคลาสย่อย (Subclass) สืบทอดความสามารถมาจาก TokenObtainPairSerializer และทำซ้ำ (Override) เมธอด get_token ดังนี้ :
# app/serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
# ปรับแต่ง: แทรกชื่อและอีเมลผู้ใช้ลงใน Payload
token['username'] = user.username
token['email'] = user.email
return token
เมื่อพัฒนาเสร็จสิ้น ให้นำตัวแปรคลาสนี้ระบุลงในพจนานุกรม TOKEN_OBTAIN_SERIALIZER ภายใต้ไดเรกทอรี settings.py ของโปรเจกต์ Django หลังบ้านเพื่อเปิดใช้งานจริงแทนคลาสสถิติเดิมของระบบ [12].
ในระบบ Simple JWT เราสามารถควบคุมระยะเวลาความสมบูรณ์และพฤติกรรมความปลอดภัยได้อย่างอิสระ ผ่านการกำหนดตัวแปรโครงข่าย SIMPLE_JWT พจนานุกรมการอัปเดตดังต่อไปนี้ [9]:
# config/settings.py (SIMPLE_JWT Dict)
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
OAuth 2.0 (RFC 6749) คือมาตรฐานการ "มอบสิทธิ์เข้าถึง" (Authorization) โดยไม่ต้องส่งรหัสผ่านให้แอป ส่วน OpenID Connect (OIDC) เป็นชั้นข้อมูลตัวตน (Identity Layer) ที่วางทับบน OAuth2 เพื่อบอกว่า "ผู้ใช้คนนี้เป็นใคร":
| Token | หน้าที่ |
|---|---|
| 🪪 ID Token | JWT ที่พิสูจน์ตัวตนผู้ใช้ (claims เช่น name, email) |
| 🎫 Access Token | กุญแจสำหรับเรียก API / userinfo |
| ♻️ Refresh Token | ใช้ขอ Access Token ชุดใหม่เมื่อหมดอายุ |
📱 Flutter เปิดเบราว์เซอร์ไปที่ GET /authorize/?response_type=code&code_challenge=…&scope=openid
👤 ผู้ใช้ล็อกอินที่หน้าเว็บ Django แล้วกดยืนยัน (Consent)
🔐 Redirect กลับ redirect_uri?code=abc123 — code ใช้ได้ครั้งเดียว อายุสั้น
📱 แอปส่ง POST /token/ พร้อม code + code_verifier
🔐 ตรวจ verifier กับ challenge สำเร็จ → ออก ID Token + Access Token (+ Refresh)
📱 ถอดรหัส ID Token เพื่อรู้จักผู้ใช้ และเก็บ Access Token ไว้เรียก API
iss sub aud exp iat ตาม RFC 7519 (สไลด์ที่ 11)/jwks/ (สไลด์ที่ 12)Endpoint มาตรฐาน /.well-known/openid-configuration จะบอก URL ของ authorize/token/userinfo/jwks ทั้งหมด — ไลบรารีฝั่งแอปใช้ URL นี้ตั้งค่าอัตโนมัติ ไม่ต้อง hard-code
Django app สำเร็จรูปที่เปลี่ยนโปรเจกต์ของเราเป็น OpenID Provider (OP) ได้ในไม่กี่บรรทัด — เป็นตัว Authorization Server ในภาพสไลด์ที่ 19:
/authorize/ /token/ /userinfo/ /jwks/ + discoverycreatersakey)⚠️ เครื่องต้องรันทั้ง backend (:8000) และ flutter (:50000) พร้อมกัน — เปิด 2 terminal
# Terminal 1 — สร้างโปรเจกต์และติดตั้ง dependencies
$ uv init oidc-backend && cd oidc-backend
$ uv add django django-oidc-provider django-cors-headers
$ uv run django-admin startproject config .
# สร้างตารางฐานข้อมูล + กุญแจ RS256 + บัญชี admin
$ uv run manage.py migrate
$ uv run manage.py creatersakey
$ uv run manage.py createsuperuser
# เริ่มเซิร์ฟเวอร์ที่ http://localhost:8000
$ uv run manage.py runserver
# config/urls.py — เปิด endpoints ของ OIDC provider
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('oidc_provider.urls',
namespace='oidc_provider')),
]
# config/settings.py — เพิ่ม 4 จุดนี้
# 1) ลงทะเบียน apps
INSTALLED_APPS = [
'django.contrib.sites', # ← เพิ่ม
'corsheaders', # ← เพิ่ม
'oidc_provider', # ← เพิ่ม
...apps เดิมของ Django...
]
# 2) ตั้งค่าที่จำเป็น
SITE_ID = 1
LOGIN_URL = '/admin/login/' # ใช้หน้า login ของ admin
# 3) CORS — อนุญาตให้ Flutter Web เรียกได้ (DEV เท่านั้น!)
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware',
*MIDDLEWARE] # ต้องวางบนสุด
CORS_ALLOW_ALL_ORIGINS = True
http://localhost:8000/admin แล้วล็อกอินด้วยบัญชี superuser ได้ = ผ่าน Step 1
แอป Flutter ของเราคือ Relying Party (RP) ที่ต้องขึ้นทะเบียนกับ OP ก่อน — ไปที่ http://localhost:8000/admin → เมนู Oidc provider › Clients › Add แล้วกรอกตามตารางด้านขวา:
เมนู Authentication and Authorization › Users › Add user — ตั้งชื่อ student01 รหัสผ่าน test1234 (กรอก email ด้วยเพื่อให้ claim email มีค่า)
client_id ให้อัตโนมัติ — คัดลอกค่านี้ไว้ใช้ในโค้ด Flutter ของ Step 3 (client_type เป็น public จะไม่มี client_secret)
| name | flutter-web-app |
| client_type | public — SPA/มือถือเก็บ secret ไม่ได้ |
| response_types | code — Authorization Code Flow |
| redirect_uris | http://localhost:50000↳ ต้องตรงกับ --web-port ของ flutter run! |
| jwt_alg | RS256 (default) |
| reuse_consent | ✔ — ไม่ถาม Consent ซ้ำทุกครั้ง |
# Terminal 2 — สร้างแอปและรันบน Chrome ที่ port 50000
$ flutter create oidc_demo --platforms web
$ cd oidc_demo
$ flutter pub add openid_client # รองรับเว็บ
$ flutter run -d chrome --web-port 50000
// lib/main.dart (ส่วนสำคัญ)
import 'package:openid_client/openid_client_browser.dart';
Future<Credential?> signIn() async {
// ① ค้นหา endpoints จาก discovery document
final issuer = await Issuer.discover(
Uri.parse('http://localhost:8000'));
final client = Client(issuer, '<client_id จาก Step 2>');
final auth = Authenticator(client,
scopes: ['openid', 'profile', 'email']);
final c = await auth.credential;
if (c == null) {
auth.authorize(); // ② ไปหน้า login ของ Django
// ③ Django redirect กลับมาหน้านี้พร้อม ?code=…
return null; // reload แล้ว c จะมีค่า
}
return c; // ④ ได้ credential สำเร็จ
}
// ปุ่มล็อกอิน + แสดงผลผู้ใช้ (ใน StatefulWidget)
ElevatedButton.icon(
icon: Icon(Icons.login),
label: Text('Sign in with OIDC'),
onPressed: () async {
final cred = await signIn();
if (cred != null) {
final info = await cred.getUserInfo();
setState(() => _user =
'${info.name} (${info.email})');
}
},
);
ครั้งแรก credential == null → authorize() เด้งไปหน้า Login ของ Django → ล็อกอินด้วย student01/test1234 → กลับมาหน้าเดิม → ตัวแปร credential ถูก restore จาก session → กดปุ่มอีกครั้งจะเห็นชื่อ+อีเมลทันที
# ตรวจ discovery document — ต้องเห็น endpoints ครบ
$ http :8000/.well-known/openid-configuration
# ตรวจกุญแจ RS256 ที่ใช้เซ็น ID Token
$ http :8000/jwks/
คัดลอกค่า id_token จากแอป (print ออก console) แล้วนำไปวางบน jwt.io — จะเห็น header alg=RS256 และ claims iss, sub, aud, exp ตรงกับที่เรียนในหมวดที่ 3
| อาการ | สาเหตุ / วิธีแก้ |
|---|---|
| redirect_uri_mismatch | URI ใน admin ไม่ตรง — ต้องเป็น http://localhost:50000 เป๊ะๆ (รวม port) |
| CORS error ใน DevTools | CorsMiddleware ต้องอยู่บนสุดของ MIDDLEWARE แล้ว restart runserver |
| invalid_client | client_id คัดลอกผิด — เปิด admin › Clients ตรวจอีกครั้ง |
| 404 /.well-known/... | ยังไม่ได้ include oidc_provider.urls ใน config/urls.py |
| login แล้วไม่กลับแอป | flutter ต้องรันด้วย --web-port 50000 ให้ตรงกับ redirect_uris ที่ลงทะเบียน |